Gareth Wilson Gareth Wilson

Guide to Dodo Payments Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDeveloper > Webhooks > Add endpoint
Subscription modelPer endpoint, by resource or by event. No selection means every event
SignatureStandard Webhooks HMAC-SHA256
Headerswebhook-id, webhook-timestamp, webhook-signature, content-type: application/json
Secretwhsec_... per endpoint, on the Overview tab
RotationRotate secret. The old secret stays valid for 24 hours
Body{business_id, type, timestamp, data}
Event routingBody field type
Timeout30 seconds for connect and read
Retries8 attempts: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours
ReplaySingle messages from Logs; bulk recover, replay-missing and bulk replay from More actions
ThrottlingOptional maximum messages per second. Excess is queued, not dropped
OrderingNot guaranteed
Source IPsA changing pool. Ranges available from support, not published

Events and payloads

Every event shares one envelope:

FieldMeaning
business_idYour Dodo Payments business identifier
typeThe event type, for example payment.succeeded. This is what handlers switch on
timestampISO 8601 time the event occurred. Use it to order events
dataEvent-specific payload. Fields vary per event
data.payload_typeResource 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

EventTriggered WhenCommon Use Cases
payment.succeededA payment is successfully processedFulfil an order, grant access
payment.failedA payment attempt fails (declined card, insufficient funds, other errors)Notify the customer, retry flow
payment.processingA payment is being processedShow a pending state
payment.cancelledA payment is cancelled before completionRelease reserved stock
refund.succeededA refund is successfully processedRevoke access, update accounting
refund.failedA refund attempt failsAlert finance
dispute.openedA cardholder opens a dispute on a paymentGather evidence
dispute.wonThe dispute is resolved in your favourClose the case
dispute.lostThe dispute is resolved in the cardholder's favourAdjust revenue, revoke access
subscription.activeA subscription is activated and recurring charges are scheduledProvision the plan
subscription.renewedA subscription renews for the next billing periodExtend access
subscription.on_holdA subscription is temporarily put on hold due to a failed renewalRestrict access, ask for a new payment method
subscription.cancelledThe merchant or customer cancels a subscriptionSchedule deprovisioning
license_key.createdA new license key is created for a productDeliver the key

Full Event List

ResourceEvents
Paymentpayment.succeeded, payment.failed, payment.processing, payment.cancelled
Refundrefund.succeeded, refund.failed
Disputedispute.opened, dispute.challenged, dispute.accepted, dispute.cancelled, dispute.expired, dispute.won, dispute.lost
Subscriptionsubscription.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 keylicense_key.created
Entitlement grantentitlement_grant.created, entitlement_grant.delivered, entitlement_grant.failed, entitlement_grant.revoked
Creditcredit.added, credit.deducted, credit.expired, credit.rolled_over, credit.rollover_forfeited, credit.overage_charged, credit.overage_reset, credit.manual_adjustment, credit.balance_low
Recoveryabandoned_checkout.detected, abandoned_checkout.recovered
Dunningdunning.started, dunning.recovered
Payoutpayout.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 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

  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:

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

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


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.