What this does
Sends a single demo webhook to your endpoint with the same headers and envelope MerchFox uses in production. Use it BEFORE you create a real subscription to verify your endpoint:
- Receives POST + parses the JSON body shape you expect
- Reads
X-Mfx-Eventand dispatches correctly - Verifies the
X-Mfx-SignatureHMAC (paste your secret below if you already have one) - Returns 2xx within your timeout (10s default in production)
This page does NOT touch MerchFox state — no subscription is created, no delivery is logged. The POST is performed by the api-docs server (Next.js route /api/test-webhook-proxy) so it arrives server-to-server, exactly like the production dispatcher does. No browser CORS preflight, no need for your endpoint to expose any CORS headers — the same setup that works in production works here.
1. Configure
Your service. For local testing use a tunnel like ngrok or cloudflared and paste the public URL.
Only event types MerchFox delivers today are listed.
If provided, we compute X-Mfx-Signature exactly like production: sha256=hmac_sha256(secret, timestamp + "." + raw_body), with the timestamp sent in X-Mfx-Timestamp. Leave blank for a no-signature smoke test.
2. Demo payload
657 bytes{
"id": "evt_01a177247486acb6323e7e53ab",
"type": "fulfillment.failed",
"api_version": "v1",
"occurred_at": "2026-10-26T07:37:20.264Z",
"delivery": {
"id": "wdel_1a177247488065e4fa238ad09",
"subscription": "wsub_DEMO_FROM_API_DOCS",
"attempt": 1
},
"data": {
"fulfillment_order_id": "6710f5a9a1b2c3d4e5f60301",
"order_id": "019ef525-3657-7a1b-9c2d-3e4f5a6b7c8d",
"status": "FAILED",
"from_status": "SUBMITTED",
"provider_id": "6650a1b2c3d4e5f6a7b8c9d1",
"provider_slug": "demo-provider",
"external_fulfillment_id": null,
"error": "Variant unavailable at the print provider",
"reason": null
}
}Same shape MerchFox sends in production — only IDs and timestamps differ. Edit app/test-webhook/page.tsx if you want to customize fields per test.
3. Send it
Or run the same delivery via curl (useful for CI / scripted tests)
BODY=$(cat <<'JSON'
{
"id": "evt_01a177247486acb6323e7e53ab",
"type": "fulfillment.failed",
"api_version": "v1",
"occurred_at": "2026-10-26T07:37:20.264Z",
"delivery": {
"id": "wdel_1a177247488065e4fa238ad09",
"subscription": "wsub_DEMO_FROM_API_DOCS",
"attempt": 1
},
"data": {
"fulfillment_order_id": "6710f5a9a1b2c3d4e5f60301",
"order_id": "019ef525-3657-7a1b-9c2d-3e4f5a6b7c8d",
"status": "FAILED",
"from_status": "SUBMITTED",
"provider_id": "6650a1b2c3d4e5f6a7b8c9d1",
"provider_slug": "demo-provider",
"external_fulfillment_id": null,
"error": "Variant unavailable at the print provider",
"reason": null
}
}
JSON
)
TS=$(date +%s)
SIG="sha256=demo-no-secret-provided"
curl -sS -i -X POST "https://webhook.site/your-uuid-here" \
-H "Content-Type: application/json" \
-H "User-Agent: MerchFox-Webhooks/1.0" \
-H "X-Mfx-Event: fulfillment.failed" \
-H "X-Mfx-Event-Id: evt_demo_$(date +%s)" \
-H "X-Mfx-Delivery-Id: wdel_demo_$(date +%s)" \
-H "X-Mfx-Subscription: wsub_DEMO_FROM_API_DOCS" \
-H "X-Mfx-Timestamp: $TS" \
-H "X-Mfx-Signature: $SIG" \
-H "X-Mfx-Api-Version: v1" \
-H "X-Mfx-Demo: true" \
-d "$BODY"Verify the signature on your side
Every delivery includes X-Mfx-Timestamp (unix seconds) and X-Mfx-Signature: sha256=<hex>. Compute HMAC-SHA256 of timestamp + "." + raw body (the bytes before JSON parsing) with your secret, compare in constant time, and reject old timestamps (for example older than 5 minutes) to stop replays.
// Node.js
const crypto = require('crypto');
const ts = req.headers['x-mfx-timestamp'];
const sig = req.headers['x-mfx-signature'] || ''; // "sha256=abc123..."
const expected = 'sha256=' + crypto
.createHmac('sha256', YOUR_WEBHOOK_SECRET)
.update(ts + '.' + rawBody) // <-- raw body, NOT JSON.parse'd
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send('bad signature');
}
# Python
import hmac, hashlib, time
ts = request.headers['X-Mfx-Timestamp']
expected = 'sha256=' + hmac.new(
secret.encode(), ts.encode() + b'.' + raw_body, hashlib.sha256
).hexdigest()
if abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(
request.headers.get('X-Mfx-Signature', ''), expected):
abort(401)