Guide to Dodo Payments Webhooks: Features and Best Practices
Dodo Payments 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_ |
| 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_, subscription.update_, subscription.cancelled, subscription.failed, subscription.expired |
| License key | license_key.created |
| Entitlement grant | entitlement_, entitlement_, entitlement_grant.failed, entitlement_ |
| Credit | credit.added, credit.deducted, credit.expired, credit.rolled_over, credit.rollover_, credit.overage_charged, credit.overage_reset, credit.manual_adjustment, credit.balance_low |
| Recovery | abandoned_, abandoned_ |
| 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_ fires 60 minutes after a checkout is left incomplete.
The credit.balance_low sample from the webhook samples library shows the envelope in full:
{
"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
- In the Dodo Payments dashboard, in test or live mode, go to Developer > Webhooks.
- Click Add endpoint and enter your HTTPS URL.
- 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.
- Click Create endpoint.
- Open the endpoint's Overview tab and copy the secret, which starts with
whsec_. Store it asDODO_, the name Dodo's SDK docs use.PAYMENTS_ WEBHOOK_ KEY
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:
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: 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.
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:
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 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
200before 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.
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
timestampof the last applied change per subscription or payout and ignore older events. - Treat
payout.successas 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-idalongside the body when you enqueue.
How Hookdeck Can Help: Hookdeck keeps the original headers with each event and its 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
200for types you don't handle.
How Hookdeck Can Help: Hookdeck 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 and our guide to 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 adds a durable queue in front of your handler, so events outlast Dodo's 8 attempts. Get a free Hookdeck account and point a Dodo endpoint at it.