PayHook

Dodo Payments webhook not working: signatures and retries

Why a Dodo Payments webhook fails verification or never reaches your app: the signing key, test and live secrets, dodo wh listen and trigger, retries, events.

If your Dodo Payments webhook handler rejects every event, the cause is almost always one of four: the key is built from the secret the wrong way, the secret belongs to the other mode, the body changed before it was verified, or the event came from dodo wh trigger, which sends no signature at all. If events never arrive, your endpoint is probably answering with something other than a 2xx in time, and Dodo is retrying it.

This page shows how Dodo signs a webhook, what each failure looks like, how delivery and retries work, which events a subscription sends, and a check in plain Node.

How Dodo Payments signs a webhook

Dodo follows the Standard Webhooks specification. Every delivery carries three headers:

HeaderWhat it holds
webhook-ida unique id of the message, the same on every retry of it
webhook-timestampwhen the message was sent, in Unix seconds
webhook-signatureone or more space-separated signatures, each v1,<base64>

The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, with the body exactly as received. The key is your webhook secret without the whsec_ prefix, base64-decoded. The signature is the HMAC-SHA256 of the signed content with that key, in base64, and a request is valid if any v1 signature in the header matches. Dodo's documentation adds one more step: reject a timestamp too far from the current time, as the Standard Webhooks libraries do with their five-minute window.

Unlike Polar, which signs with one of two keys, Dodo has one key: the base64 after the prefix. Code that uses the UTF-8 bytes of the whole secret as the key fails on every Dodo event.

What each failure looks like

The messages are those of standardwebhooks, the library that Dodo's own SDK uses to verify events.

What you seeWhyWhat to do
Missing required headers on every eventThe events come from dodo wh trigger. Its events are not signed: no webhook-id, webhook-signature or webhook-timestamp headerParse them with the SDK's unverified method (unsafeUnwrap in TypeScript) while testing, then switch back to unwrap; or send a signed example from the endpoint's Testing tab
No matching signature found on every eventThe secret belongs to the other mode: test mode and live mode keep separate endpoints, each with its own secretCopy the secret from the endpoint that sends these events, in the same mode
No matching signature found on every eventThe body was parsed and serialized again before the check, for example by a JSON middlewareVerify the raw body: express.raw({ type: "application/json" }) in Express, await req.text() in a Next.js route handler
No matching signature found, only through the CLIThe events come through dodo wh listen. The relay and the CLI parse the body and serialize it again, so its bytes can differ from what Dodo signed while the headers stay intactTest signatures with real deliveries to a public URL, or with the Testing tab; treat listen as a preview of payloads
No matching signature found after a rotationThe secret was rotated and the old one expired: it stays valid for 24 hoursUpdate the secret everywhere your handler reads it
Message timestamp too oldThe request was replayed more than five minutes after it was signed, or the server clock is offSync the clock; replay from Dodo's dashboard, which sends a fresh message

Delivery: timeouts and retries

Your endpoint must answer with a 2xx status within 30 seconds, for the connection and the read together. Any other status, or no answer in time, counts as a failure, and Dodo retries with exponential backoff, eight attempts in all:

AttemptDelay
1immediately
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 hours, the last

That is about 27.6 hours from the first attempt to the last. Two consequences follow. The same event can arrive more than once, so skip a webhook-id you have already handled. And events can arrive out of order, so order them by the timestamp field of the payload, not by arrival. Answer first and do the work in the background: an endpoint that takes longer than 30 seconds fails even when the work succeeds.

Which events a subscription sends

There is no subscription.created event. Dodo's subscription integration guide gives these sequences:

SituationEvents, in orderWhat it means for access
New subscription, no trialsubscription.active, then payment.succeeded for the first charge, within 2 to 10 minutes of checkoutThe subscription is active once the payment method is authorized
New subscription with a trialsubscription.active at checkout, with no charge; at the end of the trial, payment.succeeded together with subscription.renewedThe trial starts without a payment
Each renewalsubscription.renewed, always alongside payment.succeededExtend access on subscription.renewed, not on payment.succeeded alone
The first payment failssubscription.failed, which is terminalNever grant access; the customer must start a new subscription

So a handler that waits for payment.succeeded before it grants access keeps a new customer waiting for minutes, and one that listens only to subscription.created never grants it at all.

Verify a Dodo webhook in Node

This check uses only node:crypto. It takes the body exactly as received and fails closed on anything it cannot read.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// The signing key of a Dodo secret: the base64 after the whsec_ prefix.
// Buffer.from skips characters outside base64, so keep the key only if it
// reads back as the same text.
function dodoKey(secret) {
  const rest = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
  const key = Buffer.from(rest, "base64");
  const unpad = (text) => text.replace(/=+$/, "");
  return key.length > 0 && unpad(key.toString("base64")) === unpad(rest) ? key : null;
}

// rawBody: the body exactly as received (string or Buffer), not JSON that
// was parsed and serialized again.
export function verifyDodoWebhook(rawBody, headers, secret, now) {
  now ??= Math.floor(Date.now() / 1000);
  const key = dodoKey(secret);
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"] ?? "";
  const header = headers["webhook-signature"];
  if (!key || !id || !/^\d+$/.test(timestamp) || !header) return false;
  // fail closed: a clock that is not a number never turns the window off
  const age = Math.abs(now - Number(timestamp));
  if (!Number.isFinite(now) || !(age <= TOLERANCE_SECONDS)) return false;
  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(rawBody)]);
  const expected = createHmac("sha256", key).update(signed).digest();
  return header
    .split(" ")
    .filter((part) => part.startsWith("v1,"))
    .map((part) => Buffer.from(part.slice(3), "base64"))
    .some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected));
}

The test vector of the Standard Webhooks specification passes, and the same body serialized again does not, which is what dodo wh listen and a JSON middleware do to a real event:

const headers = {
  "webhook-id": "msg_p5jXN8AQM9LWM0D4loKWxJek",
  "webhook-timestamp": "1614265330",
  "webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
};
const secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";

// true: the body as signed
verifyDodoWebhook('{"test": 2432232314}', headers, secret, 1614265330);

// false: the same JSON without the space
verifyDodoWebhook('{"test":2432232314}', headers, secret, 1614265330);

With Dodo's SDK, give it the same raw body: client.webhooks.unwrap(rawBody, { headers }), with the secret in DODO_PAYMENTS_WEBHOOK_KEY.

How PayHook reports it

PayHook, the webhook inspector we are building, checks every Dodo event with the standard key and names the reason when it fails, such as a missing header or a signature that matched no key, without ever showing the secret. On a subscription it points out the usual gaps: a subscription.active with no payment.succeeded after it, a first payment that failed, a renewal on hold. PayHook is in closed beta; the home page has the details.

Sources

  • Dodo Payments documentation, Webhooks: headers, signature, timeouts, retries, rotation, checked on 9 October 2026.
  • Dodo Payments documentation, CLI: dodo wh listen and dodo wh trigger, checked on 9 October 2026.
  • Dodo Payments documentation, Subscription integration guide and subscription events, checked on 9 October 2026.
  • Test and live endpoints with separate secrets: PayHook's own test-mode capture of Dodo events, 6 to 7 October 2026.
  • dodopayments on npm: version 2.54.0 depends on standardwebhooks, checked on 9 October 2026.
  • The test vector: the Standard Webhooks test suite.

Back to the blog