# Dodo Payments Webhooks

[Dodo Payments](https://dodopayments.com) is a merchant-of-record payments platform covering payments, refunds, disputes, subscriptions, license keys, entitlements, credits, dunning and payouts. It POSTs a signed JSON event to each endpoint you add under **Developer > Webhooks**. Signatures follow Standard Webhooks, so each delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers.

## When to Use This Skill

* How do I receive Dodo Payments webhooks?
* How do I verify a Dodo Payments webhook signature (`webhook-signature`)?
* Where do I find my Dodo Payments webhook secret (`whsec_...`)?
* How do I handle `payment.succeeded`, `refund.succeeded`, `dispute.opened` or `subscription.active` events?
* Why is my Dodo Payments webhook verification failing, or failing only on retries and replays?
* Why does `dodo wh trigger` fail signature verification? (Mock events are unsigned.)

## Verification (core)

| Header | Value |
| --- | --- |
| `webhook-id` | Identifies the message; retries reuse it. Your idempotency key (the body has no event id) |
| `webhook-timestamp` | Unix seconds |
| `webhook-signature` | One or more space-separated `v1,<base64>` entries |

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256, base64 output. The key is the base64-**decoded** part of the `whsec_...` secret after the prefix. Compare against every `v1,` entry; ignore other versions. If `webhook-id` is absent, read `svix-id`, `svix-timestamp` and `svix-signature` instead, as Hookdeck does. The examples use the `standardwebhooks` library (npm and PyPI):

```javascript
const { Webhook } = require('standardwebhooks');

// rawBody: exact request body (Buffer or string), NOT re-serialised JSON
function verifyDodo(rawBody, headers, secret = process.env.DODO_PAYMENTS_WEBHOOK_SECRET) {
  const p = headers['webhook-id'] ? 'webhook' : 'svix'; // fall back to the svix-* names
  new Webhook(secret).verify(rawBody, {
    'webhook-id': headers[`${p}-id`],
    'webhook-timestamp': headers[`${p}-timestamp`],
    'webhook-signature': headers[`${p}-signature`],
  }, { jsonParse: false }); // throws on a bad signature or stale timestamp → respond 401
}

```

The library always enforces a fixed **5-minute timestamp window**. Hookdeck retries and replays resend the original `webhook-timestamp`, so the library rejects them once they are more than 5 minutes old. If you receive through Hookdeck and need late retries to pass, use the manual verifier in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/dodo-payments-webhooks/references/verification.md), which can drop or widen the window. In Express, read the body with `express.raw({ type: '*/*' })` so any content type verifies, and parse JSON only after the check.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/dodo-payments-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/dodo-payments-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/dodo-payments-webhooks/examples/fastapi/).

## Event Type and Payload

Every body is the same envelope, and the event name is the top-level `type`:

```json
{
  "business_id": "bus_H4ekzPSlcg",
  "type": "payment.succeeded",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": { "payload_type": "Payment" }
}

```

`timestamp` is when the event occurred (ISO 8601). `data.payload_type` names the resource kind (`Payment`, `Subscription`, `Refund`, `Dispute`, `LicenseKey`, `CreditLedgerEntry`, `CreditBalanceLow`, `AbandonedCheckout`, `DunningAttempt`, `EntitlementGrant` or `Payout`), not the event. The other `data` fields vary per event.

## Common Event Types

| Event | Fired when |
| --- | --- |
| `payment.succeeded` | A payment completes |
| `payment.failed` | A payment attempt fails (declined card, insufficient funds, other errors) |
| `payment.processing` | A payment is being processed |
| `payment.cancelled` | A payment is cancelled |
| `refund.succeeded` / `refund.failed` | A refund completes or fails |
| `dispute.opened` | A cardholder opens a dispute on a payment |
| `dispute.won` / `dispute.lost` | The dispute is resolved in your favour / the cardholder's favour |
| `subscription.active` | A subscription becomes active |
| `subscription.renewed` | A subscription renews for the next billing period |
| `subscription.on_hold` | A subscription is temporarily put on hold due to a failed renewal |
| `subscription.cancelled` | The merchant or customer cancels a subscription |
| `license_key.created` | A new license key is created for a product |

These are the events the examples route. Dodo documents 48 in all (the list may not be exhaustive): more payment, dispute (`challenged`, `accepted`, `cancelled`, `expired`) and subscription (`updated`, `past_due`, `paused`, `unpaused`, `plan_changed`, `update_payment_method`, `failed`, `expired`) events, plus `credit.*`, `abandoned_checkout.*`, `dunning.*`, `entitlement_grant.*` and `payout.*`. Ignore types you don't recognise. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/dodo-payments-webhooks/references/overview.md).

## Delivery Semantics

Return a `2xx` within the 30-second connect and read timeout, then process asynchronously. Anything else is retried with exponential backoff, 8 attempts in all (immediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h). Deduplicate on `webhook-id`. Events can arrive out of order: use `timestamp` if order matters, and note that payout events are neither terminal nor ordered. Return `400` for missing headers and `401` for a bad signature. Dodo gives no fixed source IP list: verify the signature instead of allowlisting.

## Environment Variables

```bash
DODO_PAYMENTS_WEBHOOK_SECRET=whsec_xxxxx   # full value from the endpoint's Overview tab; handlers fail closed (500) without it

```

Dodo's own SDK docs call this `DODO_PAYMENTS_WEBHOOK_KEY`. Rotating the secret keeps the old one valid for 24 hours.

## Local Development

```bash
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 dodo-payments --path /webhooks/dodo-payments

```

Set the printed URL as the endpoint URL under Developer > Webhooks, then use the endpoint's **Testing** tab to send a signed example. `dodo wh trigger` mock events are not signed.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/dodo-payments-webhooks/references/overview.md) - Event catalog, envelope and resource kinds
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/dodo-payments-webhooks/references/setup.md) - Add an endpoint, find and rotate the secret, test
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/dodo-payments-webhooks/references/verification.md) - Library and manual verification, gotchas, debugging