Help improve docs quality by sharing anonymous interaction telemetry (no PII or token data).

Webhook Verification and Retries

Register endpoints, verify signatures correctly, and process retried deliveries idempotently.

Webhooks push state changes to your system the moment they happen — a subscription upgrade, a usage threshold crossing, a credit grant — without polling. Because deliveries happen over the open internet and are retried automatically, a production-grade handler needs three things: a correct signature check, replay protection, and idempotent processing. This guide covers all three, plus the full delivery lifecycle and how to debug a failing endpoint. If you're integrating webhooks as part of a broader migration off a legacy integration, see the Migration Guide for the same dual-read rollout pattern applied to endpoint cutovers.

1. Register an endpoint

Pick from the 15 event types across 6 categories cataloged in the Events & Webhook Explorer . The signing secret (whsec_ followed by 48 hex characters, matching ^whsec_[0-9a-f]{48}$) is shown exactly once at creation — store it immediately, since later views only show the prefix. The number of endpoints you can register is governed by your plan's max_webhook_endpoints entitlement, which resolves the same way any other entitlement does — see the Entitlement Evaluation Patterns guide for the boolean-gate and limit-check shapes.

bash
# Settings → Webhooks → Add Endpoint# 1. Enter the HTTPS URL that will receive deliveries.# 2. Select which event types should trigger this endpoint.# 3. Save — the signing secret (whsec_...) is generated and shown once.
bash
curl -s -X POST \  -H "Authorization: Bearer $MONETIZEKIT_API_KEY" \  -H "Content-Type: application/json" \  -d '{"url":"https://example.com/hooks/monetizekit","events":["customer.created","subscription.updated","usage.threshold"]}' \  "$MONETIZEKIT_BASE_URL/webhooks/endpoints"# 201 -> { "id": "wh_002", "secret": "whsec_..." }  -- shown once, store it now
No CLI create command

Endpoints are registered from the Dashboard or the REST API only — the CLI's webhooks commands (below) cover testing and diagnosing existing endpoints, not creating them.

2. Verify the signature

Every delivery is signed with HMAC-SHA256 over "{timestamp}.{rawBody}" using your endpoint's signing secret. The signature is sent as X-MonetizeKit-Signature (format sha256=<hex>), and the timestamp as X-MonetizeKit-Timestamp (Unix seconds); the event type is also sent separately as X-MonetizeKit-Event. Always verify against the raw request body — a JSON parse → stringify round trip can reorder keys or change whitespace and invalidate the signature. If you're also propagating a request ID for support/debugging, see the Audit and Trace guide for how delivery IDs and request IDs correlate in the audit log.

javascript
import { createHmac, timingSafeEqual } from "node:crypto";function verify(rawBody, headers, secret) {  const timestamp = headers["x-monetizekit-timestamp"];  const signature = headers["x-monetizekit-signature"]; // "sha256=..."  // Reject stale timestamps first — this is your handler's responsibility;  // the HMAC check below only proves the signature, not the age, is valid.  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {    return false;  }  const expected =    "sha256=" +    createHmac("sha256", secret)      .update(`${timestamp}.${rawBody}`)      .digest("hex");  const a = Buffer.from(expected);  const b = Buffer.from(signature);  return a.length === b.length && timingSafeEqual(a, b);}
javascript
import { verifyWebhookSignature } from "@monetizekit/node";app.post("/hooks/monetizekit", express.raw({ type: "application/json" }), (req, res) => {  const timestamp = req.headers["x-monetizekit-timestamp"];  // verifyWebhookSignature() only checks the HMAC — it does not reject stale  // timestamps, so your handler must do it before (or after) calling it.  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {    return res.sendStatus(401);  }  const ok = verifyWebhookSignature({    rawBody: req.body, // must be the raw, unparsed body    timestamp,    signature: req.headers["x-monetizekit-signature"],    secret: process.env.MONETIZEKIT_WEBHOOK_SECRET,  });  if (!ok) return res.sendStatus(401);  handleEvent(JSON.parse(req.body));  res.sendStatus(200);});
python
import hashlib, hmac, timedef verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:    if abs(time.time() - int(timestamp)) > 300:        return False  # reject stale timestamps    payload = f"{timestamp}.".encode() + raw_body    expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()    return hmac.compare_digest(expected, signature)
go
func Verify(rawBody []byte, timestamp, signature, secret string) bool {    ts, err := strconv.ParseInt(timestamp, 10, 64)    if err != nil {        return false    }    if delta := time.Now().Unix() - ts; delta > 300 || delta < -300 {        return false // reject stale timestamps    }    mac := hmac.New(sha256.New, []byte(secret))    mac.Write([]byte(timestamp + "." + string(rawBody)))    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))    return hmac.Equal([]byte(expected), []byte(signature))}
Reject stale timestamps

Every snippet above compares X-MonetizeKit-Timestamp against the current time and rejects anything older than 5 minutes before checking the signature. This is your handler's responsibility — verifyWebhookSignature() (and the equivalent manual HMAC check) only proves the signature is valid, not that it's recent. Without the staleness check, a captured request/signature pair remains replayable indefinitely.

Try it: compute a signature

Paste a secret, timestamp, and body below to compute the expected signature interactively, or paste a signature you received to verify it against these values.

3. Process deliveries idempotently

Every delivery carries a unique X-MonetizeKit-Delivery ID that stays constant across retries of the same logical event. Store processed delivery IDs and skip work you've already done — this is what makes retries safe rather than a source of duplicate side effects (double-sent emails, double-decremented credits). The same idempotency discipline applies to usage events on your own side — see the Usage Metering Best Practices guide .

javascript
app.post("/hooks/monetizekit", async (req, res) => {  if (!verifyWebhookSignature(/* ... */)) return res.sendStatus(401);  const event = JSON.parse(req.body);  const deliveryId = req.headers["x-monetizekit-delivery"];  // Dedupe by delivery ID before processing — QStash retries reuse the same ID.  const alreadyProcessed = await store.hasProcessed(deliveryId);  if (alreadyProcessed) return res.sendStatus(200);  await handleEvent(event);  await store.markProcessed(deliveryId);  res.sendStatus(200); // respond only after the handler durably completes});

Respond 2xx only once your handler has durably committed the work — an early 200 followed by a crash before persisting looks identical to success and the event will never be retried.

Delivery lifecycle and retries

Delivery has two modes, chosen automatically based on your environment's configuration — you don't select one: when Upstash QStash is configured, delivery is queued to QStash (durable, retried on Upstash's own schedule) and immediately marked 202. Otherwise, delivery is a direct best-effort POST with a 10s timeout, and failed direct-mode deliveries are retried by an hourly job until either delivered or 5 total attempts have been made — there is no sub-hour backoff between attempts in direct mode, so exhausting the budget takes up to roughly 4 hours after the first failure.

StatusMeaning
202 (queued)Dispatched via QStash; Upstash owns retries from here on.
2xxEndpoint returned a 2xx within the 10s direct-delivery timeout.
non-2xx / timeout (retrying)Direct-mode delivery failed with budget remaining; retried on the next hourly job.
non-2xx / timeout (exhausted)All 5 attempts failed. No further retries; investigate in Recent Deliveries.

A direct-mode delivery is retried only on a non-2xx response or a timeout. Any 2xx — including 200 or 204 — stops retries, so make sure your handler doesn't return 2xx before it's actually safe to stop. An endpoint that accumulates 10 consecutive delivery failures is automatically disabled; re-enable it from Settings → Webhooks once the underlying issue is fixed. Cache invalidation triggered by these same events is covered in the Caching and Edge Evaluation guide .

Testing and debugging locally

Send a fixture event (CLI)

bash
monetizekit webhooks test https://localhost:3000/hooks/monetizekit entitlement.changed

Posts a built-in fixture payload and prints whether the endpoint responded 2xx. Its signature uses a simplified HMAC-SHA256(secret, payload) scheme (no timestamp, no sha256= prefix) — good for checking basic reachability, not for exercising your real X-MonetizeKit-Signature verification code.

Tunnel to localhost

Use a tunnel (ngrok or similar) to expose a local handler during development, and register that HTTPS tunnel URL as the endpoint. Swap in your production URL before shipping.

Inspect delivery logs

Settings → Webhooks → Recent Deliveries shows each attempt's event, endpoint, HTTP status, attempt count, and timestamp.

bash
monetizekit diagnose webhooks <endpointId>

Common pitfalls

Verifying a re-serialized body

Frameworks that auto-parse JSON before your handler runs (Express's default json() middleware) can subtly change byte-for-byte content. Use the raw body parser on the webhook route specifically.

Rotating a secret without a grace period

Rolling a signing secret invalidates the old one immediately. In-flight retries signed with the previous secret will fail verification — roll during low-traffic windows and keep the old secret ready to accept for a short overlap if your infrastructure supports dual verification.

No dedupe on X-MonetizeKit-Delivery

Retries are expected, not exceptional. If your handler isn't idempotent on delivery ID, a transient network blip on your side will duplicate side effects on the 2nd attempt.

Blocking the response on slow work

If your handler does slow downstream work (emails, third-party calls), enqueue it and respond 200 promptly once the event is durably recorded — don't let a slow downstream call trigger a delivery timeout and an unnecessary retry.

FAQ

What timeout does MonetizeKit use before treating a direct-mode delivery as failed?

10 seconds. Slow or hanging endpoints are treated the same as a failure and scheduled for retry — do the heavy work asynchronously after responding. (QStash-mode deliveries use Upstash's own timeout, not this one.)

Can I subscribe one endpoint to every event type?

Yes, but consider splitting high-volume categories (usage) from low-volume, high-stakes ones (subscriptions, credits) across separate endpoints so a slow handler for one category doesn't create a retry backlog for the other.

What happens after all 5 retry attempts fail?

The delivery keeps its last non-2xx status and no further attempts are made — there is no separate terminal "failed" status distinct from the HTTP status code it last got. It remains visible in Settings → Webhooks → Recent Deliveries for manual inspection, but there is no automatic replay — reconcile via the underlying resource's API (e.g. re-fetch the subscription) if you suspect you missed an update.

Related guides

Events & Webhook Explorer

Browse every event type and sample payload.

Open guide

Caching and Edge Evaluation

Use webhook events as cache-invalidation triggers.

Open guide

Audit and Trace

Correlate a delivery ID with an audit log entry.

Open guide

Was this page helpful?