# Guide to Dodo Payments Webhooks: Features and Best Practices

[Dodo Payments](https://dodopayments.com) is a merchant-of-record payments platform. It takes payments, runs subscriptions, handles refunds and disputes, issues license keys and credit balances, and pays out to your bank. Each of those has its own lifecycle, and Dodo's webhooks are how your product keeps access, entitlements and accounting in step with it: provision a plan on `subscription.active`, revoke it on `subscription.on_hold`, deliver a key on `license_key.created`.

What follows covers Dodo's 48 event types and its envelope, the endpoint settings in the dashboard, verifying and rotating the Standard Webhooks secret, and the retry and ordering behaviour that shapes a reliable handler.

## What are Dodo Payments webhooks?

Endpoints live under **Developer > Webhooks** in the Dodo dashboard. An endpoint is either your own HTTPS URL or one of Dodo's integration connectors (Slack, Discord, Zapier, Resend, SendGrid and others), which transform events for you and need no handler. This guide is about URL endpoints.

Each delivery is a JSON `POST` signed per Standard Webhooks with the endpoint's `whsec_` secret. The dashboard also gives each endpoint a delivery log, manual and bulk replay, throttling, custom headers and JavaScript transformations.

## Dodo Payments webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Developer > Webhooks > Add endpoint** |
| Subscription model | Per endpoint, by resource or by event. No selection means every event |
| Signature | Standard Webhooks HMAC-SHA256 |
| Headers | `webhook-id`, `webhook-timestamp`, `webhook-signature`, `content-type: application/json` |
| Secret | `whsec_...` per endpoint, on the **Overview** tab |
| Rotation | **Rotate secret**. The old secret stays valid for 24 hours |
| Body | `{business_id, type, timestamp, data}` |
| Event routing | Body field `type` |
| Timeout | 30 seconds for connect and read |
| Retries | 8 attempts: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Replay | Single messages from **Logs**; bulk recover, replay-missing and bulk replay from **More actions** |
| Throttling | Optional maximum messages per second. Excess is queued, not dropped |
| Ordering | Not guaranteed |
| Source IPs | A changing pool. Ranges available from support, not published |

## Events and payloads

Every event shares one envelope:

| Field | Meaning |
| --- | --- |
| `business_id` | Your Dodo Payments business identifier |
| `type` | The event type, for example `payment.succeeded`. This is what handlers switch on |
| `timestamp` | ISO 8601 time the event occurred. Use it to order events |
| `data` | Event-specific payload. Fields vary per event |
| `data.payload_type` | Resource kind: `Payment`, `Subscription`, `Refund`, `Dispute`, `LicenseKey`, `CreditLedgerEntry`, `CreditBalanceLow`, `AbandonedCheckout`, `DunningAttempt`, `EntitlementGrant` or `Payout`. It names the resource, not the event |

The body has no event ID. The `webhook-id` header is unique per message and stays the same across retries, so that is the key to deduplicate on.

### Common Event Types

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `payment.succeeded` | A payment is successfully processed | Fulfil an order, grant access |
| `payment.failed` | A payment attempt fails (declined card, insufficient funds, other errors) | Notify the customer, retry flow |
| `payment.processing` | A payment is being processed | Show a pending state |
| `payment.cancelled` | A payment is cancelled before completion | Release reserved stock |
| `refund.succeeded` | A refund is successfully processed | Revoke access, update accounting |
| `refund.failed` | A refund attempt fails | Alert finance |
| `dispute.opened` | A cardholder opens a dispute on a payment | Gather evidence |
| `dispute.won` | The dispute is resolved in your favour | Close the case |
| `dispute.lost` | The dispute is resolved in the cardholder's favour | Adjust revenue, revoke access |
| `subscription.active` | A subscription is activated and recurring charges are scheduled | Provision the plan |
| `subscription.renewed` | A subscription renews for the next billing period | Extend access |
| `subscription.on_hold` | A subscription is temporarily put on hold due to a failed renewal | Restrict access, ask for a new payment method |
| `subscription.cancelled` | The merchant or customer cancels a subscription | Schedule deprovisioning |
| `license_key.created` | A new license key is created for a product | Deliver the key |

### Full Event List

| Resource | Events |
| --- | --- |
| Payment | `payment.succeeded`, `payment.failed`, `payment.processing`, `payment.cancelled` |
| Refund | `refund.succeeded`, `refund.failed` |
| Dispute | `dispute.opened`, `dispute.challenged`, `dispute.accepted`, `dispute.cancelled`, `dispute.expired`, `dispute.won`, `dispute.lost` |
| Subscription | `subscription.active`, `subscription.updated`, `subscription.past_due`, `subscription.on_hold`, `subscription.paused`, `subscription.unpaused`, `subscription.renewed`, `subscription.plan_changed`, `subscription.update_payment_method`, `subscription.cancelled`, `subscription.failed`, `subscription.expired` |
| License key | `license_key.created` |
| Entitlement grant | `entitlement_grant.created`, `entitlement_grant.delivered`, `entitlement_grant.failed`, `entitlement_grant.revoked` |
| Credit | `credit.added`, `credit.deducted`, `credit.expired`, `credit.rolled_over`, `credit.rollover_forfeited`, `credit.overage_charged`, `credit.overage_reset`, `credit.manual_adjustment`, `credit.balance_low` |
| Recovery | `abandoned_checkout.detected`, `abandoned_checkout.recovered` |
| Dunning | `dunning.started`, `dunning.recovered` |
| Payout | `payout.created`, `payout.in_progress`, `payout.on_hold`, `payout.success`, `payout.failed` |

`payout.created` used to be called `payout.not_initiated`, so check old endpoint filters for the previous name. `subscription.past_due` opens a grace period in which the customer keeps access; `subscription.on_hold` is the point where access usually stops. `abandoned_checkout.detected` fires 60 minutes after a checkout is left incomplete.

The `credit.balance_low` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/dodo-payments/latest/credit.balance_low.json) shows the envelope in full:

```json
{
  "business_id": "bus_H4ekzPSlcg",
  "type": "credit.balance_low",
  "timestamp": "2025-08-04T06:15:00.000000Z",
  "data": {
    "payload_type": "CreditBalanceLow",
    "brand_id": "brd_123",
    "customer_id": "cus_8VbC6JDZzPEqfBPUdpj0K",
    "subscription_id": "sub_7EeHq2ewQuadropD2ra",
    "credit_entitlement_id": "cde_9xY2bKwQn5MjRpL8d",
    "credit_entitlement_name": "API Credits",
    "available_balance": "15",
    "subscription_credits_amount": "100",
    "threshold_percent": 20,
    "threshold_amount": "20"
  }
}

```

Credit amounts arrive as strings. Complete examples exist only for the credit and entitlement-grant events; for payments, refunds and license keys, Dodo publishes schemas without example bodies.

## Setting up Dodo Payments webhooks

1. In the Dodo Payments dashboard, in test or live mode, go to **Developer > Webhooks**.
2. Click **Add endpoint** and enter your HTTPS URL.
3. Select events. They are grouped by resource, and you can pick a whole resource or single events. Leaving everything unselected subscribes the endpoint to every type, which is rarely what you want.
4. Click **Create endpoint**.
5. Open the endpoint's **Overview** tab and copy the secret, which starts with `whsec_`. Store it as `DODO_PAYMENTS_WEBHOOK_KEY`, the name Dodo's SDK docs use.

To send a signed test event, use **Send example** in the dashboard. Mock events from the CLI's `dodo wh trigger` aren't signed, so they will fail verification. During development, expose your local server with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 dodo-payments --path /webhooks/dodo-payments

```

The endpoint's **Advanced** tab sets throttling, custom headers and transformations. A transformation that edits the payload changes the body you receive, and the signature covers the transformed body.

**With Hookdeck.** Create a **Dodo Payments** source in Hookdeck, paste the endpoint's `whsec_` secret, and add the source URL as the endpoint in Dodo. The source only takes `POST`. Hookdeck replies `200` to Dodo for every request; one with an invalid signature is marked as failed verification and isn't delivered to your connections.

## Securing Dodo Payments webhooks

Dodo restates the Standard Webhooks steps in its [webhook docs](https://docs.dodopayments.com/developer-resources/webhooks): sign `{webhook-id}.{webhook-timestamp}.{raw body}` with HMAC-SHA256, using the base64-decoded secret after `whsec_` as the key, and compare in constant time with any `v1,` entry in `webhook-signature`. During the 24 hours after a rotation, more than one entry can be present.

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Dodo's own Express sample uses `express.raw({ type: 'application/json' })`. The `'*/*'` form below works the same and doesn't depend on the content type. The handler deduplicates on `webhook-id` and orders subscription changes by `timestamp`:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/dodo-payments', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.DODO_PAYMENTS_WEBHOOK_KEY;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const messageId = req.headers['webhook-id'];
  const payload = parseJsonObject(req.body);
  if (!payload) return res.sendStatus(400); // signed, but not a JSON object
  const { type, timestamp, data } = payload;
  if (typeof type !== 'string') return res.sendStatus(400);

  try {
    await inbox.saveOnce(messageId, { type, timestamp, data }); // no-op for a repeated webhook-id
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

// A worker then applies inbox rows, skipping any subscription change older than the stored one.

```

`inbox.saveOnce` stands in for your own table or queue.

The Standard Webhooks libraries Dodo points to allow 5 minutes between `webhook-timestamp` and your clock. When Hookdeck is in front, that window works against you: Hookdeck checks no timestamp age, and a retried or replayed event keeps Dodo's original timestamp. Verify without the age check, as above, and rely on `webhook-id` to stop replays.

> **Make Dodo Payments webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Dodo's signatures, records every payment, subscription and payout event, and gives you search and replay across all of them.

## Dodo Payments webhook limitations and pain points

### Retries end after about 32 hours

**The Problem:** A long outage loses events once Dodo's attempts run out.

**Why It Happens:** Dodo makes 8 attempts spread over roughly 32 hours, then stops. Recovery after that is manual, through replay or bulk recover in the dashboard.

**Workarounds:**

* After an incident, use bulk recover from the endpoint's **More actions** menu for the affected time range.
* Return `200` before doing slow work, so the 30-second timeout never causes a failure.

**How Hookdeck Can Help:** Hookdeck accepts each event as it arrives, so Dodo's attempts succeed, and then retries to your service on your own [schedule](/docs/retries).

### Out-of-order events, including payouts

**The Problem:** A subscription flips back to an older state, or a payout you marked as settled turns out to have failed.

**Why It Happens:** Dodo says events can arrive out of order because of retries and network conditions. Payout events aren't terminal either: `payout.failed` can follow `payout.success` when a bank returns the transfer.

**Workarounds:**

* Store the `timestamp` of the last applied change per subscription or payout and ignore older events.
* Treat `payout.success` as provisional in your accounting until enough time has passed for a return.

**How Hookdeck Can Help:** Hookdeck's event log lets you see the order events actually arrived in when you reconcile a disputed state.

### No event ID in the body

**The Problem:** A queue consumer that only kept the body can't deduplicate.

**Why It Happens:** Dodo's envelope has no ID field. The idempotency key is the `webhook-id` header.

**Workarounds:**

* Save `webhook-id` alongside the body when you enqueue.

**How Hookdeck Can Help:** Hookdeck keeps the original headers with each event and its [deduplication](/docs/deduplication) can key on `webhook-id`.

### Unselected events mean all events

**The Problem:** An endpoint meant for one resource receives every event Dodo has.

**Why It Happens:** Deselecting all events in the event tree subscribes to everything, and new types will arrive as Dodo adds them.

**Workarounds:**

* Always select at least one resource or event.
* Return `200` for types you don't handle.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) on `type` can keep unwanted events away from a destination even if the endpoint subscribes broadly.

## Best practices

### Rotate inside the 24-hour window

After **Rotate secret**, the old secret keeps verifying for 24 hours. Deploy the new one within that time, or accept both during the changeover.

### Don't rely on IP allowlists

Dodo's delivery addresses change over time, and its docs say not to use them for authentication. If a firewall needs a list, ask Dodo support for current ranges, and remember that a blocked delivery counts as a failure and uses up a retry.

### Keep the raw body

Read the bytes with `express.raw()`, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI, and switch off CSRF middleware on the route.

### Acknowledge, then process

Respond inside 30 seconds and do provisioning in a worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Dodo Payments covers its whole platform with webhooks, from single payments to credit ledgers and bank payouts, using a standard signature and a well-documented retry schedule. Select events deliberately, keep the `webhook-id` with every event, order by `timestamp`, treat payout success as reversible, and rotate secrets within the 24-hour overlap.

[Hookdeck Event Gateway](https://hookdeck.com) adds a durable queue in front of your handler, so events outlast Dodo's 8 attempts. [Get a free Hookdeck account](https://dashboard.hookdeck.com/signup) and point a Dodo endpoint at it.