← API Explorer

Test webhook delivery

Sent from api-docs server (no CORS) — same as production dispatcher

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-Event and dispatches correctly
  • Verifies the X-Mfx-Signature HMAC (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)