name: webhook-integration
description: Dodo Payments webhook handling, covering endpoint setup, Standard Webhooks signature verification on the raw body, payment, subscription, and refund event types, idempotency with webhook-id, retries, and local testing. Use when receiving Dodo events, verifying webhook-signature headers, or syncing your database from payment.succeeded or subscription events.
Dodo Payments 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.
When to use this skill
- Setting up a webhook endpoint to receive payment, subscription, or refund events
- Implementing signature verification to ensure webhook authenticity
- Handling specific event types (payment succeeded, subscription renewed, etc.)
- Testing webhooks locally during development
- Ensuring idempotent webhook processing to handle retries
Core Concepts
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.
Setup: Creating a Webhook Endpoint
1. Create the endpoint in the dashboard
- Go to Developer → Webhooks
- Click Create Webhook
- Enter your endpoint URL (must be HTTPS in production)
- Select the events you want to receive
- Copy the signing secret
2. Store the signing secret
export DODO_PAYMENTS_WEBHOOK_KEY=whsec_...
The SDK reads this automatically. You can also pass it explicitly when initializing the client.
Webhook Headers and Payload
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:
{
"business_id": "bus_xxxxx",
"type": "payment.succeeded",
"timestamp": "2024-01-01T12:00:00Z",
"data": {
"payload_type": "Payment",
"payment_id": "pay_xxxxx",
"status": "succeeded",
"total_amount": 2999,
"currency": "USD",
"customer": {
"customer_id": "cus_xxxxx",
"email": "customer@example.com",
"name": "John Doe"
}
}
}
Verification: The Centerpiece
Always verify the signature before processing. Unverified webhooks can be spoofed.
Preferred: SDK helper
The simplest and safest approach. The SDK handles all verification details.
TypeScript/Node:
import DodoPayments from 'dodopayments';
import express from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
app.post('/webhook', async (req, res) => {
try {
const unwrapped = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
// Signature verified. Process the event.
console.log(`Received ${unwrapped.type}`);
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: 'Invalid signature' });
}
});
Python:
from typing import Literal
from fastapi import FastAPI, Request, HTTPException
from dodopayments import DodoPayments
import os
# `environment` is Literal["live_mode", "test_mode"], not str. Passing the raw
# variable through raises at construction: unset gives
# `ValueError: Unknown environment: None`, and a typo like "test" gives
# `Unknown environment: test`. Narrow it, defaulting to test mode so a
# misconfigured variable can never select live.
ENVIRONMENT: Literal["live_mode", "test_mode"] = (
"live_mode" if os.getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" else "test_mode"
)
app = FastAPI()
client = DodoPayments(
bearer_token=os.getenv("DODO_PAYMENTS_API_KEY"),
environment=ENVIRONMENT,
webhook_key=os.getenv("DODO_PAYMENTS_WEBHOOK_KEY"),
)
@app.post("/webhook")
async def handle_webhook(request: Request):
try:
unwrapped = client.webhooks.unwrap(
await request.body(),
headers={
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
},
)
# Signature verified. Process the event.
return {"received": True}
except Exception:
raise HTTPException(status_code=401, detail="Invalid signature")
Go:
import (
"io"
"net/http"
"os"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
// The Go SDK has no WithEnvironment(string). It exposes two explicit options,
// so narrow here and default to test mode: an unset or misspelled variable must
// never select live mode.
func dodoEnvironment() option.RequestOption {
if os.Getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" {
return option.WithEnvironmentLiveMode()
}
return option.WithEnvironmentTestMode()
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
dodoEnvironment(),
option.WithWebhookKey(os.Getenv("DODO_PAYMENTS_WEBHOOK_KEY")),
)
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
// Unwrap takes the raw body and the request headers directly. It is not
// context-aware and does not take a map: the signature is
// Unwrap(payload []byte, headers http.Header, opts ...option.RequestOption).
if _, err := client.Webhooks.Unwrap(rawBody, r.Header); err != nil {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature verified. Process the event.
w.WriteHeader(http.StatusOK)
}
Alternative: standardwebhooks package
If you prefer manual verification or don't use the Dodo SDK:
import { Webhook } from "standardwebhooks";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
const webhook = new Webhook(process.env.DODO_PAYMENTS_WEBHOOK_KEY);
app.post("/webhooks/dodo", async (req, res) => {
try {
const payload = req.body.toString();
await webhook.verify(payload, req.headers);
const event = JSON.parse(payload);
console.log(`Received ${event.type}`);
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: "Invalid signature" });
}
});
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.
Raw Body Requirement
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) |
Webhook Event Catalog
Full guide: references/event-catalog.md.
Covers:
- Payment events
- Subscription events
- Refund events
- Dispute events
- License key events
- Entitlement grant events
- Credit events
- Recovery and dunning events
- Payout events
Production-Grade Handler Pattern
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:
import DodoPayments from 'dodopayments';
import express from 'express';
import { Queue, Worker } from 'bullmq';
// BullMQ is illustrative; use your preferred durable async queue.
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
type WebhookJob = { event: WebhookEvent; webhookId: string };
const connection = {
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? '6379'),
};
const eventQueue = new Queue<WebhookJob>('webhook-events', { connection });
app.post('/webhook', async (req, res) => {
let unwrapped: WebhookEvent;
try {
unwrapped = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
} catch (error) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Durably enqueue before acknowledging, keyed by webhook-id for idempotency
const webhookId = req.headers['webhook-id'] as string;
try {
await eventQueue.add(
`process-${unwrapped.type}`,
{ event: unwrapped, webhookId },
{
jobId: webhookId, // Prevents duplicate queue entries
attempts: 8,
backoff: { type: 'exponential', delay: 1000 },
}
);
return res.json({ received: true });
} catch (error) {
console.error('Failed to persist webhook', error);
return res.status(503).json({ error: 'Webhook persistence failed' });
}
});
// Async worker. webhookLog.webhookId must have a unique constraint.
const worker = new Worker<WebhookJob>(
'webhook-events',
async (job) => {
const { event, webhookId } = job.data;
await db.$transaction(async (tx) => {
const claim = await tx.webhookLog.createMany({
data: [{ webhookId, eventType: event.type }],
skipDuplicates: true,
});
if (claim.count === 0) return;
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSucceeded(event.data, tx);
break;
case 'subscription.active':
await handleSubscriptionActive(event.data, tx);
break;
// ... handle other events
}
});
},
{ connection }
);
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.
Delivery Semantics
Understand how Dodo delivers webhooks:
| Property | Behavior |
|---|
| Timeout | 30 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 |
Framework Adaptor Shortcuts
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 | Webhooks({ webhookKey, onPayload }) - register express.json() first, not express.raw() (it verifies against the parsed req.body) |
| 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.
// app/api/webhook/dodo-payments/route.ts
import { Webhooks } from "@dodopayments/nextjs";
export const POST = Webhooks({
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
onPayload: async (payload) => {
// payload is already verified
console.log(`Received ${payload.type}`);
if (payload.type !== 'payment.succeeded') return;
// webhookLog.webhookId must have a unique constraint. If fulfillment
// throws, the claim rolls back and Dodo's redelivery can retry it.
await db.$transaction(async (tx) => {
const claim = await tx.webhookLog.createMany({
data: [{
webhookId: payload.data.payment_id,
eventType: payload.type,
}],
skipDuplicates: true,
});
if (claim.count === 0) return;
await handlePaymentSucceeded(payload.data, tx);
});
},
});
Handle subscription.active only in a handler that exposes webhook-id, then commit that claim and the entitlement changes in the same transaction.
Local Testing
Full guide: references/local-testing.md.
Covers:
- Dashboard test tool
- CLI: Live forwarding
- CLI: Unsigned mock events
- Tunnel
Common Mistakes
Full guide: references/common-mistakes.md.
Covers:
- Signing anything other than
webhook-id.webhook-timestamp.raw_body
- Re-serializing the parsed body
- Naive comma-splitting of the signature header
timingSafeEqual throwing on length mismatch
- Granting access from
return_url instead of verified webhooks
- Doing slow work before responding 2xx
- Acknowledging before durable persistence
- Committing an idempotency claim before fulfillment
Resources