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.
# 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.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 nowEndpoints 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.
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);}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);});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)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))}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 .
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.
| Status | Meaning |
|---|---|
202 (queued) | Dispatched via QStash; Upstash owns retries from here on. |
2xx | Endpoint 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)
monetizekit webhooks test https://localhost:3000/hooks/monetizekit entitlement.changedPosts 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.
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.
Caching and Edge Evaluation
Use webhook events as cache-invalidation triggers.
Audit and Trace
Correlate a delivery ID with an audit log entry.