How to Validate Shopify Webhooks in Make.com? Full HMAC Verification Guide

TL;DR — Shopify HMAC Verification in Make.com
- Make.com’s Custom Webhook doesn’t expose the raw request body. It parses JSON before you can access it. HMAC verification requires the raw bytes, so you can’t do it natively inside a Make.com scenario.
- The working solution is a Cloudflare Worker sitting in front of your Make.com webhook URL. It receives the raw request, verifies the HMAC, and only forwards to Make.com on success.
- The HMAC algorithm:
Base64(HMAC-SHA256(shopify_secret, raw_body)). Compare the result with theX-Shopify-Hmac-SHA256header using a constant-time comparison to prevent timing attacks. - Shopify expects a 200 within 5 seconds. The Cloudflare Worker adds roughly 1–2ms of latency and handles this comfortably.
- The free Cloudflare Workers tier covers 100,000 requests/day — sufficient for most Shopify integrations without paying anything.
⚠ Your Make.com Webhook URL Is Publicly Accessible
Make.com webhook URLs follow a predictable format and aren’t secret. If you’ve shared scenario screenshots publicly, posted in support forums, or had a Make.com account compromised, that URL could be known. Without HMAC verification, any request to that URL triggers your scenario. For scenarios that write orders to a database, trigger fulfillment, update inventory, or send customer emails, an unverified endpoint is a meaningful operational risk.
Shopify signs every webhook it sends with an HMAC-SHA256 signature in the X-Shopify-Hmac-SHA256 header. Verifying that signature before processing the event is how you confirm the request actually came from Shopify and not from someone who found your webhook URL and decided to send fabricated order data to your Make.com scenario. Without verification, your endpoint is open: anyone who can POST to it can trigger your automation.
I ran without HMAC verification for eight months. Not because I didn’t know it existed — Shopify’s documentation mentions it clearly — but because I assumed Make.com’s webhook trigger handled it. It doesn’t. I found out by testing: I sent a POST request to my own webhook URL with a fake order payload and a made-up HMAC header, and my scenario processed it without complaint. The scenario ran, the downstream actions triggered, and nothing flagged it as invalid. That test result was uncomfortable enough that I fixed it the same day.
The problem with implementing HMAC verification in Make.com is that Make.com’s Custom Webhook module automatically parses the incoming JSON body before you can do anything with it. HMAC verification requires the raw request body — the exact bytes Shopify signed — and Make.com doesn’t expose that. This post covers the architecture that actually works: a Cloudflare Worker that intercepts the webhook, verifies the signature against the raw body, and forwards clean requests to Make.com. It also covers the Make.com-native partial approach and why I don’t use it for production.
How Shopify’s HMAC Signature Works
When Shopify sends a webhook, it computes a signature using your webhook secret as the HMAC key and the raw request body as the message. It base64-encodes the result and attaches it in the X-Shopify-Hmac-SHA256 header.
On your end, you do the same computation with the same key and the same raw body. If your result matches the header value, the request is authentic. If it doesn’t — either because the body was tampered with or because the secret doesn’t match — you reject it with a 401.
HMAC verification — Python reference implementation
import hmac
import hashlib
import base64
def verify_shopify_webhook(
raw_body: bytes,
hmac_header: str,
secret: str
) -> bool:
"""
Verifies a Shopify webhook HMAC signature.
Args:
raw_body: The raw request body bytes — NOT parsed JSON.
This must be the exact bytes received from Shopify.
hmac_header: Value of the X-Shopify-Hmac-SHA256 header.
secret: Your Shopify webhook secret (from app settings
or the specific webhook's secret).
Returns:
True if the signature is valid, False otherwise.
"""
computed = base64.b64encode(
hmac.new(
key=secret.encode("utf-8"),
msg=raw_body,
digestmod=hashlib.sha256
).digest()
).decode("utf-8")
# constant-time comparison — prevents timing attacks
return hmac.compare_digest(computed, hmac_header)
# Usage in a Flask webhook handler (for reference):
@app.route("/webhooks/shopify", methods=["POST"])
def shopify_webhook():
raw_body = request.get_data() # Raw bytes — critical
hmac_header = request.headers.get("X-Shopify-Hmac-SHA256", "")
if not verify_shopify_webhook(raw_body, hmac_header, SHOPIFY_SECRET):
return "Unauthorized", 401
# Safe to process
event = request.get_json()
process_event(event)
return "OK", 200
Two details in the above that matter more than they look:
request.get_data() returns the raw bytes before any parsing. Using request.get_json() and then re-serializing with json.dumps() doesn’t work — the whitespace, key ordering, and encoding in the re-serialized version won’t match what Shopify originally signed.
hmac.compare_digest() instead of == does a constant-time string comparison. A standard equality check terminates as soon as it finds a mismatch, and the time it takes leaks information about how many characters matched. An attacker making many requests can use this timing information to progressively construct a valid signature. Constant-time comparison takes the same amount of time regardless of where the mismatch occurs.
Why Make.com Can’t Do This Natively
Make.com’s Custom Webhook module is convenient for most webhook use cases, and I say what I’m about to say as someone whose business runs a significant number of Make.com scenarios: the lack of raw body access is a genuine gap that Shopify developers hit constantly, and Make.com hasn’t addressed it despite Shopify being one of the most common integration targets on the platform.
Make.com automatically parses the incoming JSON body the moment a request arrives. By the time any module in your scenario can access the data, it’s already been converted from raw bytes into structured bundle items. You can’t get the original byte string back — not through any formula, not through any built-in function, not through any module configuration I’ve found after looking fairly carefully. For HMAC verification, which requires the exact bytes Shopify signed, this makes native verification impossible.
The partial workaround some people describe is using Make.com’s built-in sha256() formula function to compute a hash of the parsed body and compare it to the header. This doesn’t work correctly because sha256() is not HMAC-SHA256 (HMAC uses a secret key; a plain hash doesn’t), and because hashing the parsed body isn’t the same as hashing the raw bytes regardless of algorithm. It’s a plausible-sounding approach that fails both on security and on matching Shopify’s expected computation.
n8n, for comparison, exposes $request.rawBody in Code nodes, making webhook HMAC verification a one-function implementation. It’s a single line of difference in platform capability that forces Make.com users into an intermediary architecture that n8n users don’t need. This is the kind of thing that doesn’t show up in feature comparison tables but does show up in production.
The Solution: Cloudflare Worker as Verification Middleware
A Cloudflare Worker sits between Shopify and Make.com. Shopify sends the webhook to your Worker URL. The Worker reads the raw body before any parsing occurs, computes the HMAC, verifies against the header, and forwards the request to Make.com on success. Make.com receives a pre-verified request and processes it normally.
The Worker runs at Cloudflare’s edge — the request never touches a server you manage. Latency is 1–2ms. The free tier handles 100,000 requests/day and 10ms CPU time per request, which is more than sufficient for HMAC computation. My current setup processes around 1,300 Shopify webhook events per day across all scenarios and sits well within the free tier limits.
Cloudflare Worker — Shopify HMAC verification + Make.com forward
// wrangler.toml
// [vars]
// MAKE_WEBHOOK_URL = "https://hook.eu1.make.com/your-webhook-id"
// SHOPIFY_WEBHOOK_SECRET = "your_shopify_secret_here"
export default {
async fetch(request, env) {
// Only accept POST requests
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const hmacHeader = request.headers.get("X-Shopify-Hmac-Sha256");
if (!hmacHeader) {
return new Response("Missing HMAC header", { status: 401 });
}
// Read raw body BEFORE any parsing — this is the critical step
const rawBody = await request.text();
// Compute HMAC-SHA256
const isValid = await verifyShopifyHmac(
rawBody,
hmacHeader,
env.SHOPIFY_WEBHOOK_SECRET
);
if (!isValid) {
console.error("HMAC verification failed", {
received: hmacHeader,
path: new URL(request.url).pathname,
});
return new Response("Unauthorized", { status: 401 });
}
// Verified — forward to Make.com
const makeResponse = await fetch(env.MAKE_WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Forward the original Shopify topic header if your scenario uses it
"X-Shopify-Topic": request.headers.get("X-Shopify-Topic") || "",
"X-Shopify-Shop-Domain": request.headers.get("X-Shopify-Shop-Domain") || "",
},
body: rawBody,
});
// Return 200 to Shopify immediately — Make.com's response time doesn't matter
return new Response("OK", { status: 200 });
},
};
async function verifyShopifyHmac(body, hmacHeader, secret) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(body)
);
// Convert ArrayBuffer to base64
const computed = btoa(
String.fromCharCode(...new Uint8Array(signature))
);
// Constant-time comparison
return timingSafeEqual(computed, hmacHeader);
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
Deploying the Worker
Install Wrangler (Cloudflare's CLI) and deploy in three commands:
Deploy to Cloudflare Workers
# Install Wrangler npm install -g wrangler # Authenticate with Cloudflare wrangler login # Set secrets (not in wrangler.toml — stored encrypted in Cloudflare) wrangler secret put SHOPIFY_WEBHOOK_SECRET # Paste your Shopify webhook secret when prompted wrangler secret put MAKE_WEBHOOK_URL # Paste your Make.com webhook URL when prompted # Deploy wrangler deploy # Your Worker URL will be: https://shopify-verify.your-subdomain.workers.dev # Update your Shopify webhook endpoint to point here
The secret values are stored encrypted in Cloudflare — they're not in your source code, not in wrangler.toml, and not accessible via the dashboard after you set them. This is the correct way to handle the Shopify webhook secret. Do not put it in the [vars] section of wrangler.toml — that's for non-sensitive config values and is visible in your Cloudflare dashboard.
Where to Find Your Shopify Webhook Secret
The webhook secret depends on how you're receiving Shopify webhooks:
Custom App webhooks (Admin API): In your Shopify Partner dashboard, go to your app → API credentials → Client secret. This is the secret used to sign all webhooks from that app.
Webhook subscriptions created via the Admin API: The secret is the same Client Secret from your app credentials. Every webhook subscription created by your app uses the same signing key.
Shopify Admin manually configured webhooks (Settings → Notifications → Webhooks): Each manually configured webhook has its own signing secret, visible by clicking the webhook entry in the admin. This is separate from your app's Client Secret.
The most common mistake I see: using the API key instead of the Client Secret. They're different values in the same credentials panel. The API key is used for authentication when making API requests. The Client Secret is used for signing webhooks. They look similar (both are alphanumeric strings) and are listed adjacent to each other, which is how this mix-up happens. If your HMAC verification is consistently failing on every request, check which value you're using first.
Updating Shopify to Send Webhooks to Your Worker
Once the Worker is deployed, update your Shopify webhook endpoint from the Make.com URL to the Worker URL. For manually configured webhooks, this is Settings → Notifications → Webhooks in the Shopify admin. For API-managed webhook subscriptions, update the address field in the webhook subscription record.
Shopify provides a "Send test notification" button on each webhook in the admin. Use this to confirm the flow works end to end before pointing live traffic at the Worker. A successful test will show as a 200 in your Cloudflare Worker logs (Workers → your worker → Logs in the dashboard) and should trigger your Make.com scenario as expected.
If the test fails with a 401, check: the secret value in the Worker matches the Shopify webhook secret exactly (no trailing whitespace, same Client Secret vs webhook-specific secret), and the Worker is receiving the raw body correctly. Add a console.log(rawBody.substring(0, 100)) temporarily to confirm the body is arriving and readable in the Worker log.
Handling Multiple Shopify Stores or Webhook Topics
If you're receiving webhooks from multiple Shopify stores or need to route different webhook topics to different Make.com scenarios, the Worker can handle this with a routing layer.
Multi-store / multi-topic routing in the Worker
// Route webhooks by topic header to different Make.com scenarios
// Each route has its own Make.com webhook URL stored as a Worker secret
const TOPIC_ROUTES = {
"orders/create": env.MAKE_ORDERS_CREATE_URL,
"orders/cancelled": env.MAKE_ORDERS_CANCELLED_URL,
"products/update": env.MAKE_PRODUCTS_UPDATE_URL,
"customers/create": env.MAKE_CUSTOMERS_CREATE_URL,
};
// After HMAC verification passes:
const topic = request.headers.get("X-Shopify-Topic");
const makeUrl = TOPIC_ROUTES[topic];
if (!makeUrl) {
// Topic not routed — acknowledge Shopify but don't forward
console.log(`Unhandled topic: ${topic}`);
return new Response("OK", { status: 200 });
}
const makeResponse = await fetch(makeUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: rawBody,
});
return new Response("OK", { status: 200 });
For multiple stores with different secrets, add the store domain to the routing logic. Shopify sends the store domain in the X-Shopify-Shop-Domain header, which you can use to look up the correct secret for that store before verification.
What Happens When Shopify Retries
Shopify retries failed webhook deliveries up to 19 times over 48 hours if it doesn't receive a 2xx response. The Worker returns 200 immediately regardless of what Make.com does with the forwarded request. This is intentional — Make.com's webhook processing time is irrelevant to Shopify's delivery confirmation.
The potential issue: if Make.com is unavailable or slow when the Worker forwards the request, the Worker still returns 200 to Shopify (which stops retries), but the event never reached Make.com. For high-stakes webhooks — order creation, payment capture — consider adding a queue between the Worker and Make.com rather than forwarding directly. The Worker writes the verified payload to a Cloudflare Queue (also free tier); a separate Consumer reads from the queue and forwards to Make.com with its own retry logic.
For most Shopify integrations, direct forwarding is fine. The scenario where Make.com is unavailable at the exact moment a webhook arrives and the 200 prevents a retry is rare enough that the added complexity of a queue isn't worth it unless the business impact of a missed event is significant.
What This Architecture Gives You
- Verified webhook authenticity before any Make.com scenario runs
- Immediate 200 response to Shopify — no timeout risk from Make.com latency
- Topic-based routing to different scenarios from one endpoint
- Centralized rejection logging — failed verifications in one place
- Zero infrastructure to manage — Cloudflare handles it
- Free for up to 100,000 requests/day
Tradeoffs to Know
- One more system to maintain — Worker code needs updating if routing changes
- Make.com unavailability silently drops events (Worker already returned 200)
- Secret rotation requires updating both Shopify and the Worker secret
- Cloudflare Worker cold starts add ~5ms on first request after idle (rare)
- Make.com still has no native way to verify webhooks it receives directly
Testing Your Verification Setup
Three tests worth running before considering this done:
Test 1: Valid webhook. Use Shopify's "Send test notification" button in Settings → Notifications → Webhooks. Confirm the Worker logs show a successful verification and Make.com receives and processes the event.
Test 2: Invalid HMAC. Send a POST to your Worker URL with a valid JSON body but a made-up HMAC header value. The Worker should return 401 and Make.com should receive nothing. This is the test I ran against my original unprotected Make.com URL that prompted this whole setup.
Test script — verify your Worker rejects invalid HMACs
import requests
WORKER_URL = "https://shopify-verify.your-subdomain.workers.dev"
# Test 1: Valid signature (should return 200)
import hmac, hashlib, base64, json
secret = "your_shopify_secret_here"
payload = json.dumps({"id": 12345, "email": "test@example.com"})
valid_hmac = base64.b64encode(
hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest()
).decode()
r = requests.post(
WORKER_URL,
data=payload,
headers={
"Content-Type": "application/json",
"X-Shopify-Hmac-Sha256": valid_hmac,
"X-Shopify-Topic": "orders/create",
}
)
print(f"Valid HMAC → {r.status_code} (expected: 200)")
# Test 2: Invalid signature (should return 401)
r = requests.post(
WORKER_URL,
data=payload,
headers={
"Content-Type": "application/json",
"X-Shopify-Hmac-Sha256": "dGhpcyBpcyBub3QgYSB2YWxpZCBzaWduYXR1cmU=",
"X-Shopify-Topic": "orders/create",
}
)
print(f"Invalid HMAC → {r.status_code} (expected: 401)")
# Test 3: Missing signature (should return 401)
r = requests.post(
WORKER_URL,
data=payload,
headers={"Content-Type": "application/json"},
)
print(f"No HMAC header → {r.status_code} (expected: 401)")
Test 3: Body tampering. Compute a valid HMAC for one payload, then send a different payload with that HMAC. The Worker should return 401 because the HMAC was computed over the original body, not the modified one. This confirms the signature actually protects the content and not just the header presence.
ℹ Implementation Checklist
Before deploying: Confirm you have the correct Shopify secret (Client Secret, not API Key). Store it as an encrypted Worker secret, not in wrangler.toml. Identify which Make.com webhook URL(s) the Worker should forward to.
After deploying: Run all three tests above. Update the webhook endpoint URL in Shopify to point to the Worker. Verify the Cloudflare logs show requests arriving and being processed correctly on the next real Shopify event.
Ongoing: If you rotate your Shopify webhook secret, update the Worker secret with wrangler secret put SHOPIFY_WEBHOOK_SECRET and redeploy. The new secret takes effect immediately without redeploying the Worker code.
FAQ
Can I use this same Worker pattern for other platforms besides Shopify?
Yes, with minor adjustments per provider. GitHub uses X-Hub-Signature-256 and prefixes the computed HMAC with sha256=. Stripe uses Stripe-Signature with a timestamp-based scheme that's slightly more complex (you prepend the timestamp to the payload before computing the HMAC). WooCommerce uses the same base64 HMAC-SHA256 approach as Shopify. The core Worker structure stays the same; you swap in the provider-specific header name and any payload construction differences in the verification function.
Does this mean I need to manage another service alongside Make.com?
The Worker is effectively zero-maintenance once deployed. There's no server to monitor, no instance to restart, no logs to rotate. Cloudflare handles all of that. The only maintenance events are: updating the routing table when you add a new Shopify webhook topic to Make.com, and rotating the secret if Shopify requires it. The first takes a code change and a wrangler deploy. The second takes one CLI command. In practice, I've touched the Worker code twice in six months.
What if I'd rather not use Cloudflare at all?
Any environment that gives you access to the raw request body before JSON parsing works. A Vercel Edge Function or Netlify Edge Function has the same capabilities as a Cloudflare Worker with minimal syntax differences. A small Express server on a $5 VPS works and gives you more flexibility at the cost of managing the instance. A Fly.io or Railway deployment is a middle ground. The Cloudflare Worker approach gets recommended most often because it's genuinely zero infrastructure — but the verification code itself is environment-agnostic.
My HMAC verification is failing on every request. What should I check?
In order of frequency: wrong secret value (API key vs Client Secret — check this first); trailing whitespace or newline in the secret when you set the Worker environment variable; the body is being read twice in the Worker (the second request.text() call returns an empty string because the stream is consumed — always read body once and store the result); the Shopify shop is using a webhook-specific secret rather than the app Client Secret. To debug, temporarily log both the computed HMAC and the received header value in the Worker — even a single character difference will show up immediately.
Is there any way to do this inside Make.com without a Worker?
Not correctly. The approaches that seem like they might work don't: Make.com's sha256() function produces a plain SHA256 hash, not an HMAC. Re-serializing the parsed JSON body with toString() doesn't reproduce the original raw bytes. A Make.com HTTP module that calls a verification endpoint is just adding the Worker pattern inside Make.com instead of outside it, which is strictly worse. The platform doesn't expose raw request body access, and that's the fundamental requirement for HMAC verification. The Worker approach is the right tool for this specific job.


