Hi there!
I am currently trying to develop a small app to help with address validation. The app is embedded into Shopify Admin where it shows some small information on how to use and uses a “shopify functions” extension to validate the address field in checkout. I also used the shopify CLI with the remix app template to create the app and installed it inside my dev store.
So far i implemented the GDPR webhooks, which are accecpted by store tests and am stuggling now on HMAC implementation. I mainly followed these two guides:
- How to configure GDPR Compliance webhooks in Shopify Public Apps. | by Muhammad Ehsanullah | Medium
- How do I implement HMAC signature for webhook verification in a Remix-Run app? - #5 by FyRo
This is my current status:
Starting with the first guide i implemented the webhooks.
from my shopify.app.toml:
[build]
automatically_update_urls_on_dev = true
[webhooks]
api_version = "2025-07"
[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]]
topics = [ "app/scopes_update" ]
uri = "/webhooks/app/scopes_update"
[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "/webhooks"
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "write_products"
[auth]
redirect_urls = ["https://painted-muslim-immediately-machines.trycloudflare.com/auth/callback", "https://painted-muslim-immediately-machines.trycloudflare.com/auth/shopify/callback", "https://painted-muslim-immediately-machines.trycloudflare.com/api/auth/callback"]
[pos]
embedded = false
Then in /app/routes i created a webhooks.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { verifyWebhook } from "app/verifyWebhooks";
export const action = async ({ request }: ActionFunctionArgs) => {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
try {
// Clone the request before passing it to authenticate.webhook()
const requestClone = request.clone();
const trustworthy = await verifyWebhook(requestClone);
console.log("TRUSTWORTHY WEBHOOK: %o", trustworthy);
if (!trustworthy) return new Response("Invalid HMAC", { status: 401 });
// Authenticate the webhook request
const { shop, payload, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook from ${shop}`);
console.log("Payload:", JSON.stringify(payload, null, 2));
switch (topic) {
case "CUSTOMERS_DATA_REQUEST":
//Logic for requesting custoers data goes here...
console.log(`📌 No customer data stored. Responding to data request for shop: ${shop}`);
break;
case "CUSTOMERS_REDACT":
//Logic for removing customer data goes here...
console.log(`📌 No customer data stored. Ignoring redaction request for shop: ${shop}`);
break;
case "SHOP_REDACT":
//Logic for removing shop data goes here...
console.log(`📌 No shop data stored. Acknowledging shop deletion request for shop: ${shop}`);
break;
default:
console.warn(`❌ Unhandled webhook topic: ${topic}`);
return new Response("Unhandled webhook topic", { status: 400 });
}
// Return 200 only if authentication and processing succeeded
return new Response("Webhook received", { status: 200 });
} catch (authError) {
// Specifically handle HMAC validation failures
console.error("🔒 Webhook authentication failed:", authError);
return new Response("Webhook HMAC validation failed", { status: 401 });
}
};
This got the GPDR webhooks green!
And finally in /app i created verifyWebhook.ts
import crypto from "node:crypto";
export const verifyWebhook = async (request: Request) => {
const incomingHmac = request.headers.get("x-shopify-hmac-sha256");
if (!incomingHmac) return false;
const secret = process.env.SHOPIFY_API_SECRET;
if (!secret) {
console.warn("Missing SHOPIFY_API_SECRET");
return false;
}
// Use raw bytes (not text) to avoid encoding issues
const raw = Buffer.from(await request.arrayBuffer());
const computed = crypto.createHmac("sha256", secret).update(raw).digest(); // Buffer
let received: Buffer;
try {
received = Buffer.from(incomingHmac, "base64");
} catch {
return false;
}
const hmacValid = crypto.timingSafeEqual(computed, received);
return hmacValid;
};
Now i went back to the shopify cli for testing.
after running
shopify app webhook trigger --topic customers/redact
i got
11:13:01 │ remix │ TRUSTWORTHY WEBHOOK: true
11:13:01 │ remix │ Received CUSTOMERS_REDACT webhook from shop.myshopify.com
11:13:01 │ remix │ Payload: {
11:13:01 │ remix │ "shop_id": 954889,
11:13:01 │ remix │ "shop_domain": "{shop}.myshopify.com",
11:13:01 │ remix │ "customer": {
11:13:01 │ remix │ "id": 191167,
11:13:01 │ remix │ "email": "john@example.com",
11:13:01 │ remix │ "phone": "555-625-1199"
11:13:01 │ remix │ },
11:13:01 │ remix │ "orders_to_redact": [
11:13:01 │ remix │ 299938,
11:13:01 │ remix │ 280263,
11:13:01 │ remix │ 220458
11:13:01 │ remix │ ]
11:13:01 │ remix │ }
11:13:01 │ remix │ 📌 No customer data stored. Ignoring redaction request for shop: shop.myshopify.com
in my shopify app dev terminal. And now here i am kinda stuck. What am i missing? Do i need to set up a deticated domain with TSL first? Did i implement the hmac validation incorrectly?
Any help would be appreciated since theres so much different information about this topic floating around. Thank you!
Best regards
jch4nni
