# Webhook Verification and Retries

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

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/webhooks
<!-- /docs-source -->

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](/docs/guides/migration) 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](/docs/guides/events) . 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](/docs/guides/entitlement-patterns) 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
```

> [!NOTE] 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](/docs/guides/audit-trace) 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, time

def 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))
}
```

> [!WARNING] 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](/docs/guides/metering) .

```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.

| 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](/docs/guides/caching) .

## 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](/docs/guides/events)

### Caching and Edge Evaluation

Use webhook events as cache-invalidation triggers.

[Open guide](/docs/guides/caching)

### Audit and Trace

Correlate a delivery ID with an audit log entry.

[Open guide](/docs/guides/audit-trace)