npx skills add ...
npx skills add dodopayments/skills --skill webhook-integration
Complete guide for setting up and handling Dodo Payments webhooks for real-time payment event notifications.
npx skills add dodopayments/skills --skill webhook-integration
Webhooks deliver real-time notifications when payment events occur. Use them to automate workflows, update databases, send confirmations, and keep your systems in sync.
Webhook: An HTTP POST request sent by Dodo to your endpoint when an event occurs.
Signature verification: Cryptographic proof that a webhook came from Dodo, not an attacker. Required for production.
Idempotency: Processing the same webhook multiple times produces the same result. Use the webhook-id header to detect and skip duplicates.
Raw body: The exact bytes received from Dodo, before parsing. Required for signature verification.
The SDK reads this automatically. You can also pass it explicitly when initializing the client.
Every webhook request includes three required headers (all lowercase, hyphenated):
| Header | Example | Purpose |
|---|---|---|
webhook-id | evt_abc123 | Unique identifier for this webhook delivery |
webhook-signature | v1,base64_signature_here | HMAC-SHA256 signature for verification |
webhook-timestamp | 1704067200 | Unix timestamp (seconds) when the event was sent |
The request body is JSON:
Always verify the signature before processing. Unverified webhooks can be spoofed.
The simplest and safest approach. The SDK handles all verification details.
TypeScript/Node:
Python:
Go:
standardwebhooks packageIf you prefer manual verification or don't use the Dodo SDK:
unwrap() vs unsafeUnwrap()unwrap() verifies the signature. Use this for all production webhooks.unsafeUnwrap() skips verification. Use only for unsigned test payloads from dodo wh trigger.Signature verification requires the exact bytes Dodo sent. If you parse the JSON first and then re-serialize it, the bytes change and verification fails.
Framework-specific setup:
| Framework | Raw body setup |
|---|---|
| Express | app.use(express.raw({ type: 'application/json' })) |
| Next.js | req.text() in route handlers; no middleware needed |
| Fastify | Custom content-type parser (see adaptor docs) |
| Hono | Built-in; no special setup |
| FastAPI | await request.body() returns bytes |
| Go | io.ReadAll(r.Body) |
Dodo sends 40+ event types across nine domains. Subscribe to only the events you need.
| Event | When it fires | What to do |
|---|---|---|
payment.succeeded | Payment completed successfully | Grant access, send confirmation, update order status |
payment.failed | Payment attempt failed | Notify customer, suggest retry or alternative payment method |
payment.processing | Payment is still being processed | Acknowledge receipt, wait for payment.succeeded or payment.failed |
payment.cancelled | Payment was cancelled before completion | Update order status, notify customer if applicable |
| Event | When it fires | What to do |
|---|---|---|
subscription.active | Subscription becomes active; recurring charges are scheduled | Grant subscription access, send welcome email |
subscription.updated | Any field on the subscription changes | Sync changes to your database |
subscription.on_hold | Failed renewal temporarily pauses the subscription | Notify customer, prompt payment method update |
subscription.renewed | Subscription amount successfully deducted for a billing period | Log renewal, update next billing date |
subscription.plan_changed | Plan upgraded, downgraded, or modified | Update customer's access level or feature set |
subscription.update_payment_method | Payment method is updated | Sync the new payment method to your records |
subscription.cancelled | Merchant or customer cancels the subscription | Revoke access, send cancellation confirmation |
subscription.failed | Subscription creation fails (mandate creation failed) | Notify customer, suggest alternative payment method |
subscription.expired | Subscription reaches the end of its term | Revoke access, offer renewal or upgrade |
| Event | When it fires | What to do |
|---|---|---|
refund.succeeded | Refund successfully processed | Update order status, revoke access if applicable |
refund.failed | Refund processing fails | Alert team, investigate reason |
| Event | When it fires | What to do |
|---|---|---|
dispute.opened | Customer initiates a dispute | Alert team, prepare evidence |
dispute.expired | Dispute expires without resolution | Log outcome |
dispute.accepted | Merchant accepts the dispute | Process refund if not already done |
dispute.cancelled | Customer or system cancels the dispute | Log outcome |
dispute.challenged | Merchant challenges the dispute | Prepare additional evidence |
dispute.won | Merchant wins the dispute | Log outcome, retain funds |
dispute.lost | Merchant loses the dispute | Process refund, log outcome |
| Event | When it fires | What to do |
|---|---|---|
license_key.created | License key is generated | Send key to customer (legacy; prefer entitlement_grant.delivered) |
| Event | When it fires | What to do |
|---|---|---|
entitlement_grant.created | Grant row is created | Prepare for fulfillment |
entitlement_grant.delivered | Fulfillment completes; customer receives access | Grant platform, file, or license-key access |
entitlement_grant.failed | Delivery fails and is no longer retried | Alert team, inspect error_code and error_message |
entitlement_grant.revoked | Access is withdrawn | Revoke customer access, inspect revocation_reason |
These concern virtual credit entitlements, not monetary wallet balances.
| Event | When it fires | What to do |
|---|---|---|
credit.added | Credits granted via subscription, purchase, add-on, or API | Update internal credit balance, log grant |
credit.deducted | Usage or manual debit consumes credits | Update internal credit balance |
credit.expired | Unused credits reach expiry | Log expiration, notify customer if applicable |
credit.rolled_over | Unused credits carried into a new grant | Update internal balance |
credit.rollover_forfeited | Credits forfeited at max rollover count | Log forfeiture |
credit.overage_charged | Overage charged after usage exceeds balance | Update internal balance, notify customer |
credit.overage_reset | Accumulated overage reset (e.g., new billing cycle) | Update internal balance |
credit.manual_adjustment | Manual credit or debit adjustment made | Update internal balance, log adjustment |
credit.balance_low | Balance falls below configured threshold | Notify customer, suggest purchase |
| Event | When it fires | What to do |
|---|---|---|
abandoned_checkout.detected | Failed or incomplete checkout classified as abandoned (after 60 min) | Monitor recovery link usage |
abandoned_checkout.recovered | Customer pays through recovery link | Log recovery, update order status |
dunning.started | Dunning attempt begins after subscription enters on_hold | Monitor dunning progress |
dunning.recovered | Customer updates payment method and charge succeeds | Reactivate subscription, send confirmation |
Respond quickly after durably recording the event, process asynchronously, and use idempotency keys. In the worker, insert the idempotency claim and apply all durable business changes in one database transaction. If processing throws, the transaction rolls back the claim so the job can retry safely:
The handlers above must perform entitlement writes through tx. Queue emails or other external work through a transactional outbox; a database transaction cannot roll back an already-sent external request.
Understand how Dodo delivers webhooks:
| Property | Behavior |
|---|---|
| Timeout | 15 seconds for connection and read |
| Success | Any 2xx response acknowledges delivery. Return 200 immediately after durably recording the event. |
| Failure | Any non-2xx response triggers a retry. |
| Retries | Eight attempts: immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h |
| Idempotency | Use webhook-id to detect and skip duplicates |
| Ordering | No guarantee. Events can arrive out of order. |
| Payload freshness | Delivery contains the latest resource state at delivery time |
| Transport | Use HTTPS in production |
If you use a supported framework, use the official adaptor package for built-in webhook handling:
| Framework | Package | Webhook handler |
|---|---|---|
| Next.js | @dodopayments/nextjs | Webhooks({ webhookKey, onPayload }) |
| Nuxt | @dodopayments/nuxt | Webhooks({ webhookKey, onPayload }) |
| Express | @dodopayments/express | Middleware with raw-body parser |
| Fastify | @dodopayments/fastify | Webhooks({ webhookKey, onPayload }) |
| Hono | @dodopayments/hono | Webhooks({ webhookKey, onPayload }) |
| Astro | @dodopayments/astro | Webhooks({ webhookKey, onPayload }) |
| SvelteKit | @dodopayments/sveltekit | Webhooks({ webhookKey, onPayload }) |
| Remix | @dodopayments/remix | Webhooks({ webhookKey, onPayload }) |
| TanStack Start | @dodopayments/tanstack | Webhooks({ webhookKey, onPayload }) |
| Bun | @dodopayments/bun | Webhooks({ webhookKey, onPayload }) |
| Better Auth | @dodopayments/better-auth | Plugin with webhooks({ webhookKey, onPayload }) |
| Convex | @dodopayments/convex | Component with verified HTTP handler |
Next.js example:
The adapter callback does not expose webhook-id, so this payment example uses payment_id as its stable key and commits the claim and durable fulfillment together. Use a handler that exposes webhook-id for event types without a verified stable identifier.
Handle subscription.active only in a handler that exposes webhook-id, then commit that claim and the entitlement changes in the same transaction.
200 and the signature verifiesForward real test-mode events to localhost:
This creates a test webhook, opens a WebSocket relay, and forwards events with valid signatures to your local URL. Requires a test-mode API key. The URL argument is required in direct mode — bare dodo wh listen only works as /wh listen inside the TUI.
Generate realistic unsigned payloads for testing without signature verification:
Use unsafeUnwrap() only for these unsigned payloads. Both arguments are required in direct mode.
Expose localhost with ngrok and register the HTTPS URL in the dashboard:
timestamp.payloadWrong:
Correct: The signed message is webhook-id.webhook-timestamp.raw_body, all three parts joined by periods. Use the SDK helper to avoid this entirely.
Wrong:
Correct: Always use the raw bytes:
Wrong:
The header is versioned and can contain multiple values. Use the SDK helper.
timingSafeEqual throwing on length mismatchWrong:
Correct: Use the SDK helper, which handles this safely.
return_url instead of verified webhooksWrong:
Correct: Grant access only after receiving and verifying a webhook:
Wrong:
Correct: Durably enqueue first, then respond immediately. Return non-2xx if persistence fails so Dodo retries:
Wrong:
Correct: Verify the signature, durably insert or enqueue the event, and only then return 2xx. If persistence fails, return non-2xx so Dodo retries.
Wrong:
The retry sees the existing claim and skips fulfillment, permanently dropping the event.
Correct: Commit the claim and all durable fulfillment changes in one transaction. A failure rolls both back, so the retry can claim the event again:
Use a transactional outbox for email or other external effects that must follow the database commit.