@dodopayments/opencode-plugin 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,82 +5,62 @@ description: Complete guide for setting up and handling Dodo Payments webhooks f
5
5
 
6
6
  # Dodo Payments Webhook Integration
7
7
 
8
- **Reference: [docs.dodopayments.com/developer-resources/webhooks](https://docs.dodopayments.com/developer-resources/webhooks)**
8
+ Webhooks deliver real-time notifications when payment events occur. Use them to automate workflows, update databases, send confirmations, and keep your systems in sync.
9
9
 
10
- Webhooks provide real-time notifications when payment events occur. Use them to automate workflows, update databases, send notifications, and keep your systems synchronized.
10
+ ## When to use this skill
11
+
12
+ - Setting up a webhook endpoint to receive payment, subscription, or refund events
13
+ - Implementing signature verification to ensure webhook authenticity
14
+ - Handling specific event types (payment succeeded, subscription renewed, etc.)
15
+ - Testing webhooks locally during development
16
+ - Ensuring idempotent webhook processing to handle retries
11
17
 
12
18
  ---
13
19
 
14
- ## Quick Setup
20
+ ## Core Concepts
21
+
22
+ **Webhook**: An HTTP POST request sent by Dodo to your endpoint when an event occurs.
23
+
24
+ **Signature verification**: Cryptographic proof that a webhook came from Dodo, not an attacker. Required for production.
25
+
26
+ **Idempotency**: Processing the same webhook multiple times produces the same result. Use the `webhook-id` header to detect and skip duplicates.
27
+
28
+ **Raw body**: The exact bytes received from Dodo, before parsing. Required for signature verification.
29
+
30
+ ---
15
31
 
16
- ### 1. Configure Webhook in Dashboard
17
- 1. Go to Dashboard → Developer → Webhooks
18
- 2. Click "Create Webhook"
19
- 3. Enter your endpoint URL
20
- 4. Select events to subscribe to
21
- 5. Copy the webhook secret
32
+ ## Setup: Creating a Webhook Endpoint
33
+
34
+ ### 1. Create the endpoint in the dashboard
35
+
36
+ 1. Go to **Developer → Webhooks**
37
+ 2. Click **Create Webhook**
38
+ 3. Enter your endpoint URL (must be HTTPS in production)
39
+ 4. Select the events you want to receive
40
+ 5. Copy the signing secret
41
+
42
+ ### 2. Store the signing secret
22
43
 
23
- ### 2. Environment Variables
24
44
  ```bash
25
- DODO_PAYMENTS_WEBHOOK_SECRET=your_webhook_secret_here
45
+ export DODO_PAYMENTS_WEBHOOK_KEY=whsec_...
26
46
  ```
27
47
 
28
- ---
48
+ The SDK reads this automatically. You can also pass it explicitly when initializing the client.
29
49
 
30
- ## Webhook Events
31
-
32
- ### Payment Events
33
- | Event | Description |
34
- |-------|-------------|
35
- | `payment.succeeded` | Payment completed successfully |
36
- | `payment.failed` | Payment attempt failed |
37
- | `payment.processing` | Payment is being processed |
38
- | `payment.cancelled` | Payment was cancelled |
39
-
40
- ### Subscription Events
41
- | Event | Description |
42
- |-------|-------------|
43
- | `subscription.active` | Subscription is now active |
44
- | `subscription.updated` | Subscription details changed |
45
- | `subscription.on_hold` | Subscription on hold (failed renewal) |
46
- | `subscription.renewed` | Subscription renewed successfully |
47
- | `subscription.plan_changed` | Plan upgraded/downgraded |
48
- | `subscription.cancelled` | Subscription cancelled |
49
- | `subscription.failed` | Subscription creation failed |
50
- | `subscription.expired` | Subscription term ended |
51
-
52
- ### Other Events
53
- | Event | Description |
54
- |-------|-------------|
55
- | `refund.succeeded` | Refund processed successfully |
56
- | `dispute.opened` | New dispute received |
57
- | `license_key.created` | License key generated |
58
-
59
- ### Credit Events
60
- | Event | Description |
61
- |-------|-------------|
62
- | `credit.added` | Credits granted to a customer (subscription, one-time, or API) |
63
- | `credit.deducted` | Credits consumed through usage or manual debit |
64
- | `credit.expired` | Unused credits expired after configured period |
65
- | `credit.rolled_over` | Unused credits carried forward at cycle end |
66
- | `credit.rollover_forfeited` | Credits forfeited at max rollover count |
67
- | `credit.overage_charged` | Overage charges applied beyond zero balance |
68
- | `credit.manual_adjustment` | Manual credit/debit adjustment via dashboard or API |
69
- | `credit.balance_low` | Credit balance dropped below configured threshold |
70
50
  ---
71
51
 
72
- ## Webhook Payload Structure
52
+ ## Webhook Headers and Payload
73
53
 
74
- ### Request Headers
75
- ```http
76
- POST /your-webhook-url
77
- Content-Type: application/json
78
- webhook-id: evt_xxxxx
79
- webhook-signature: v1,signature_here
80
- webhook-timestamp: 1234567890
81
- ```
54
+ Every webhook request includes three required headers (all lowercase, hyphenated):
55
+
56
+ | Header | Example | Purpose |
57
+ |--------|---------|---------|
58
+ | `webhook-id` | `evt_abc123` | Unique identifier for this webhook delivery |
59
+ | `webhook-signature` | `v1,base64_signature_here` | HMAC-SHA256 signature for verification |
60
+ | `webhook-timestamp` | `1704067200` | Unix timestamp (seconds) when the event was sent |
61
+
62
+ The request body is JSON:
82
63
 
83
- ### Payload Format
84
64
  ```json
85
65
  {
86
66
  "business_id": "bus_xxxxx",
@@ -89,441 +69,659 @@ webhook-timestamp: 1234567890
89
69
  "data": {
90
70
  "payload_type": "Payment",
91
71
  "payment_id": "pay_xxxxx",
72
+ "status": "succeeded",
92
73
  "total_amount": 2999,
93
74
  "currency": "USD",
94
75
  "customer": {
95
- "customer_id": "cust_xxxxx",
76
+ "customer_id": "cus_xxxxx",
96
77
  "email": "customer@example.com",
97
78
  "name": "John Doe"
98
79
  }
99
- // ... additional event-specific fields
100
80
  }
101
81
  }
102
82
  ```
103
83
 
104
84
  ---
105
85
 
106
- ## Implementation Examples
86
+ ## Verification: The Centerpiece
87
+
88
+ **Always verify the signature before processing.** Unverified webhooks can be spoofed.
89
+
90
+ ### Preferred: SDK helper
91
+
92
+ The simplest and safest approach. The SDK handles all verification details.
107
93
 
108
- ### Next.js (App Router)
94
+ **TypeScript/Node:**
109
95
 
110
96
  ```typescript
111
- // app/api/webhooks/dodo/route.ts
112
- import { NextRequest, NextResponse } from 'next/server';
113
- import crypto from 'crypto';
114
-
115
- const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;
116
-
117
- function verifySignature(payload: string, signature: string, timestamp: string): boolean {
118
- const signedPayload = `${timestamp}.${payload}`;
119
- const expectedSignature = crypto
120
- .createHmac('sha256', WEBHOOK_SECRET)
121
- .update(signedPayload)
122
- .digest('base64');
123
-
124
- // Extract signature from "v1,signature" format
125
- const providedSig = signature.split(',')[1];
126
-
127
- return crypto.timingSafeEqual(
128
- Buffer.from(expectedSignature),
129
- Buffer.from(providedSig || '')
130
- );
131
- }
97
+ import DodoPayments from 'dodopayments';
98
+ import express from 'express';
132
99
 
133
- export async function POST(req: NextRequest) {
134
- const body = await req.text();
135
- const signature = req.headers.get('webhook-signature') || '';
136
- const timestamp = req.headers.get('webhook-timestamp') || '';
137
- const webhookId = req.headers.get('webhook-id');
100
+ const app = express();
101
+ app.use(express.raw({ type: 'application/json' }));
138
102
 
139
- // Verify signature
140
- if (!verifySignature(body, signature, timestamp)) {
141
- return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
142
- }
103
+ // `environment` is a narrow union, but env vars are `string | undefined`.
104
+ // Narrow explicitly rather than casting, and default to test mode so a missing
105
+ // variable can never accidentally hit live.
106
+ const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
143
107
 
144
- // Check timestamp to prevent replay attacks (5 minute tolerance)
145
- const eventTime = parseInt(timestamp) * 1000;
146
- if (Math.abs(Date.now() - eventTime) > 300000) {
147
- return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 });
148
- }
108
+ const client = new DodoPayments({
109
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY,
110
+ environment,
111
+ webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
112
+ });
149
113
 
150
- const event = JSON.parse(body);
151
-
152
- // Handle events
153
- switch (event.type) {
154
- case 'payment.succeeded':
155
- await handlePaymentSucceeded(event.data);
156
- break;
157
- case 'payment.failed':
158
- await handlePaymentFailed(event.data);
159
- break;
160
- case 'subscription.active':
161
- await handleSubscriptionActive(event.data);
162
- break;
163
- case 'subscription.cancelled':
164
- await handleSubscriptionCancelled(event.data);
165
- break;
166
- case 'refund.succeeded':
167
- await handleRefundSucceeded(event.data);
168
- break;
169
- case 'dispute.opened':
170
- await handleDisputeOpened(event.data);
171
- break;
172
- case 'license_key.created':
173
- await handleLicenseKeyCreated(event.data);
174
- break;
175
- case 'credit.added':
176
- await handleCreditAdded(event.data);
177
- break;
178
- case 'credit.deducted':
179
- await handleCreditDeducted(event.data);
180
- break;
181
- case 'credit.balance_low':
182
- await handleCreditBalanceLow(event.data);
183
- break;
184
- default:
185
- console.log(`Unhandled event type: ${event.type}`);
114
+ app.post('/webhook', async (req, res) => {
115
+ try {
116
+ const unwrapped = client.webhooks.unwrap(req.body.toString(), {
117
+ headers: {
118
+ 'webhook-id': req.headers['webhook-id'] as string,
119
+ 'webhook-signature': req.headers['webhook-signature'] as string,
120
+ 'webhook-timestamp': req.headers['webhook-timestamp'] as string,
121
+ },
122
+ });
123
+
124
+ // Signature verified. Process the event.
125
+ console.log(`Received ${unwrapped.type}`);
126
+ res.json({ received: true });
127
+ } catch (error) {
128
+ res.status(401).json({ error: 'Invalid signature' });
186
129
  }
130
+ });
131
+ ```
187
132
 
188
- return NextResponse.json({ received: true });
189
- }
133
+ **Python:**
190
134
 
191
- async function handlePaymentSucceeded(data: any) {
192
- const { payment_id, customer, total_amount, product_id, subscription_id } = data;
193
-
194
- // Update database
195
- // Send confirmation email
196
- // Grant access to product
197
- console.log(`Payment ${payment_id} succeeded for ${customer.email}`);
198
- }
135
+ ```python
136
+ from typing import Literal
137
+ from fastapi import FastAPI, Request, HTTPException
138
+ from dodopayments import DodoPayments
139
+ import os
140
+
141
+ # `environment` is Literal["live_mode", "test_mode"], not str. Passing the raw
142
+ # variable through raises at construction: unset gives
143
+ # `ValueError: Unknown environment: None`, and a typo like "test" gives
144
+ # `Unknown environment: test`. Narrow it, defaulting to test mode so a
145
+ # misconfigured variable can never select live.
146
+ ENVIRONMENT: Literal["live_mode", "test_mode"] = (
147
+ "live_mode" if os.getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" else "test_mode"
148
+ )
199
149
 
200
- async function handlePaymentFailed(data: any) {
201
- const { payment_id, customer, error_message } = data;
202
-
203
- // Log failure
204
- // Notify customer
205
- // Update UI state
206
- console.log(`Payment ${payment_id} failed: ${error_message}`);
207
- }
150
+ app = FastAPI()
151
+ client = DodoPayments(
152
+ bearer_token=os.getenv("DODO_PAYMENTS_API_KEY"),
153
+ environment=ENVIRONMENT,
154
+ webhook_key=os.getenv("DODO_PAYMENTS_WEBHOOK_KEY"),
155
+ )
208
156
 
209
- async function handleSubscriptionActive(data: any) {
210
- const { subscription_id, customer, product_id, next_billing_date } = data;
211
-
212
- // Grant subscription access
213
- // Update user record
214
- // Send welcome email
215
- console.log(`Subscription ${subscription_id} activated for ${customer.email}`);
216
- }
157
+ @app.post("/webhook")
158
+ async def handle_webhook(request: Request):
159
+ try:
160
+ unwrapped = client.webhooks.unwrap(
161
+ await request.body(),
162
+ headers={
163
+ "webhook-id": request.headers.get("webhook-id", ""),
164
+ "webhook-signature": request.headers.get("webhook-signature", ""),
165
+ "webhook-timestamp": request.headers.get("webhook-timestamp", ""),
166
+ },
167
+ )
168
+ # Signature verified. Process the event.
169
+ return {"received": True}
170
+ except Exception:
171
+ raise HTTPException(status_code=401, detail="Invalid signature")
172
+ ```
217
173
 
218
- async function handleSubscriptionCancelled(data: any) {
219
- const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
220
-
221
- // Schedule access revocation
222
- // Send cancellation confirmation
223
- console.log(`Subscription ${subscription_id} cancelled`);
224
- }
174
+ **Go:**
225
175
 
226
- async function handleRefundSucceeded(data: any) {
227
- const { refund_id, payment_id, amount } = data;
228
-
229
- // Update order status
230
- // Revoke access if needed
231
- console.log(`Refund ${refund_id} processed for payment ${payment_id}`);
232
- }
176
+ ```go
177
+ import (
178
+ "io"
179
+ "net/http"
180
+ "os"
233
181
 
234
- async function handleDisputeOpened(data: any) {
235
- const { dispute_id, payment_id, amount, dispute_status } = data;
236
-
237
- // Alert team
238
- // Prepare evidence
239
- console.log(`Dispute ${dispute_id} opened for payment ${payment_id}`);
240
- }
182
+ "github.com/dodopayments/dodopayments-go"
183
+ "github.com/dodopayments/dodopayments-go/option"
184
+ )
241
185
 
242
- async function handleLicenseKeyCreated(data: any) {
243
- const { id, key, product_id, customer_id, expires_at } = data;
244
-
245
- // Store license key
246
- // Send to customer
247
- console.log(`License key created: ${key.substring(0, 8)}...`);
186
+ // The Go SDK has no WithEnvironment(string). It exposes two explicit options,
187
+ // so narrow here and default to test mode: an unset or misspelled variable must
188
+ // never select live mode.
189
+ func dodoEnvironment() option.RequestOption {
190
+ if os.Getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" {
191
+ return option.WithEnvironmentLiveMode()
192
+ }
193
+ return option.WithEnvironmentTestMode()
248
194
  }
249
195
 
250
- async function handleCreditAdded(data: any) {
251
- const { customer_id, credit_entitlement_id, amount, balance_after } = data;
252
-
253
- // Update internal credit balance
254
- // Log credit grant
255
- console.log(`${amount} credits added for customer ${customer_id}, balance: ${balance_after}`);
196
+ func webhookHandler(w http.ResponseWriter, r *http.Request) {
197
+ client := dodopayments.NewClient(
198
+ option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
199
+ dodoEnvironment(),
200
+ option.WithWebhookKey(os.Getenv("DODO_PAYMENTS_WEBHOOK_KEY")),
201
+ )
202
+
203
+ rawBody, err := io.ReadAll(r.Body)
204
+ if err != nil {
205
+ http.Error(w, "Cannot read body", http.StatusBadRequest)
206
+ return
207
+ }
208
+
209
+ // Unwrap takes the raw body and the request headers directly. It is not
210
+ // context-aware and does not take a map: the signature is
211
+ // Unwrap(payload []byte, headers http.Header, opts ...option.RequestOption).
212
+ if _, err := client.Webhooks.Unwrap(rawBody, r.Header); err != nil {
213
+ http.Error(w, "Invalid signature", http.StatusUnauthorized)
214
+ return
215
+ }
216
+
217
+ // Signature verified. Process the event.
218
+ w.WriteHeader(http.StatusOK)
256
219
  }
220
+ ```
257
221
 
258
- async function handleCreditDeducted(data: any) {
259
- const { customer_id, credit_entitlement_id, amount, balance_after } = data;
260
-
261
- // Update internal credit balance
262
- // Check if balance is getting low
263
- console.log(`${amount} credits deducted for customer ${customer_id}, balance: ${balance_after}`);
264
- }
222
+ ### Alternative: `standardwebhooks` package
265
223
 
266
- async function handleCreditBalanceLow(data: any) {
267
- const { customer_id, credit_entitlement_name, available_balance, threshold_percent } = data;
268
-
269
- // Notify customer about low balance
270
- // Suggest upgrading plan or purchasing more credits
271
- console.log(`Low balance alert: ${available_balance} ${credit_entitlement_name} remaining for ${customer_id}`);
272
- }
224
+ If you prefer manual verification or don't use the Dodo SDK:
225
+
226
+ ```typescript
227
+ import { Webhook } from "standardwebhooks";
228
+ import express from "express";
229
+
230
+ const app = express();
231
+ app.use(express.raw({ type: "application/json" }));
232
+
233
+ const webhook = new Webhook(process.env.DODO_PAYMENTS_WEBHOOK_KEY);
234
+
235
+ app.post("/webhooks/dodo", async (req, res) => {
236
+ try {
237
+ const payload = req.body.toString();
238
+ await webhook.verify(payload, req.headers);
239
+
240
+ const event = JSON.parse(payload);
241
+ console.log(`Received ${event.type}`);
242
+ res.json({ received: true });
243
+ } catch (error) {
244
+ res.status(401).json({ error: "Invalid signature" });
245
+ }
246
+ });
273
247
  ```
274
248
 
275
- ### Express.js
249
+ ### `unwrap()` vs `unsafeUnwrap()`
250
+
251
+ - `unwrap()` verifies the signature. Use this for all production webhooks.
252
+ - `unsafeUnwrap()` skips verification. Use only for unsigned test payloads from `dodo wh trigger`.
253
+
254
+ ---
255
+
256
+ ## Raw Body Requirement
257
+
258
+ 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.
259
+
260
+ **Framework-specific setup:**
261
+
262
+ | Framework | Raw body setup |
263
+ |-----------|---|
264
+ | **Express** | `app.use(express.raw({ type: 'application/json' }))` |
265
+ | **Next.js** | `req.text()` in route handlers; no middleware needed |
266
+ | **Fastify** | Custom content-type parser (see adaptor docs) |
267
+ | **Hono** | Built-in; no special setup |
268
+ | **FastAPI** | `await request.body()` returns bytes |
269
+ | **Go** | `io.ReadAll(r.Body)` |
270
+
271
+ ---
272
+
273
+ ## Webhook Event Catalog
274
+
275
+ Dodo sends 40+ event types across nine domains. Subscribe to only the events you need.
276
+
277
+ ### Payment events
278
+
279
+ | Event | When it fires | What to do |
280
+ |-------|---|---|
281
+ | `payment.succeeded` | Payment completed successfully | Grant access, send confirmation, update order status |
282
+ | `payment.failed` | Payment attempt failed | Notify customer, suggest retry or alternative payment method |
283
+ | `payment.processing` | Payment is still being processed | Acknowledge receipt, wait for `payment.succeeded` or `payment.failed` |
284
+ | `payment.cancelled` | Payment was cancelled before completion | Update order status, notify customer if applicable |
285
+
286
+ ### Subscription events
287
+
288
+ | Event | When it fires | What to do |
289
+ |-------|---|---|
290
+ | `subscription.active` | Subscription becomes active; recurring charges are scheduled | Grant subscription access, send welcome email |
291
+ | `subscription.updated` | Any field on the subscription changes | Sync changes to your database |
292
+ | `subscription.on_hold` | Failed renewal temporarily pauses the subscription | Notify customer, prompt payment method update |
293
+ | `subscription.renewed` | Subscription amount successfully deducted for a billing period | Log renewal, update next billing date |
294
+ | `subscription.plan_changed` | Plan upgraded, downgraded, or modified | Update customer's access level or feature set |
295
+ | `subscription.update_payment_method` | Payment method is updated | Sync the new payment method to your records |
296
+ | `subscription.cancelled` | Merchant or customer cancels the subscription | Revoke access, send cancellation confirmation |
297
+ | `subscription.failed` | Subscription creation fails (mandate creation failed) | Notify customer, suggest alternative payment method |
298
+ | `subscription.expired` | Subscription reaches the end of its term | Revoke access, offer renewal or upgrade |
299
+
300
+ ### Refund events
301
+
302
+ | Event | When it fires | What to do |
303
+ |-------|---|---|
304
+ | `refund.succeeded` | Refund successfully processed | Update order status, revoke access if applicable |
305
+ | `refund.failed` | Refund processing fails | Alert team, investigate reason |
306
+
307
+ ### Dispute events
308
+
309
+ | Event | When it fires | What to do |
310
+ |-------|---|---|
311
+ | `dispute.opened` | Customer initiates a dispute | Alert team, prepare evidence |
312
+ | `dispute.expired` | Dispute expires without resolution | Log outcome |
313
+ | `dispute.accepted` | Merchant accepts the dispute | Process refund if not already done |
314
+ | `dispute.cancelled` | Customer or system cancels the dispute | Log outcome |
315
+ | `dispute.challenged` | Merchant challenges the dispute | Prepare additional evidence |
316
+ | `dispute.won` | Merchant wins the dispute | Log outcome, retain funds |
317
+ | `dispute.lost` | Merchant loses the dispute | Process refund, log outcome |
318
+
319
+ ### License key events
320
+
321
+ | Event | When it fires | What to do |
322
+ |-------|---|---|
323
+ | `license_key.created` | License key is generated | Send key to customer (legacy; prefer `entitlement_grant.delivered`) |
324
+
325
+ ### Entitlement grant events
326
+
327
+ | Event | When it fires | What to do |
328
+ |-------|---|---|
329
+ | `entitlement_grant.created` | Grant row is created | Prepare for fulfillment |
330
+ | `entitlement_grant.delivered` | Fulfillment completes; customer receives access | Grant platform, file, or license-key access |
331
+ | `entitlement_grant.failed` | Delivery fails and is no longer retried | Alert team, inspect `error_code` and `error_message` |
332
+ | `entitlement_grant.revoked` | Access is withdrawn | Revoke customer access, inspect `revocation_reason` |
333
+
334
+ ### Credit events
335
+
336
+ These concern virtual credit entitlements, not monetary wallet balances.
337
+
338
+ | Event | When it fires | What to do |
339
+ |-------|---|---|
340
+ | `credit.added` | Credits granted via subscription, purchase, add-on, or API | Update internal credit balance, log grant |
341
+ | `credit.deducted` | Usage or manual debit consumes credits | Update internal credit balance |
342
+ | `credit.expired` | Unused credits reach expiry | Log expiration, notify customer if applicable |
343
+ | `credit.rolled_over` | Unused credits carried into a new grant | Update internal balance |
344
+ | `credit.rollover_forfeited` | Credits forfeited at max rollover count | Log forfeiture |
345
+ | `credit.overage_charged` | Overage charged after usage exceeds balance | Update internal balance, notify customer |
346
+ | `credit.overage_reset` | Accumulated overage reset (e.g., new billing cycle) | Update internal balance |
347
+ | `credit.manual_adjustment` | Manual credit or debit adjustment made | Update internal balance, log adjustment |
348
+ | `credit.balance_low` | Balance falls below configured threshold | Notify customer, suggest purchase |
349
+
350
+ ### Recovery and dunning events
351
+
352
+ | Event | When it fires | What to do |
353
+ |-------|---|---|
354
+ | `abandoned_checkout.detected` | Failed or incomplete checkout classified as abandoned (after 60 min) | Monitor recovery link usage |
355
+ | `abandoned_checkout.recovered` | Customer pays through recovery link | Log recovery, update order status |
356
+ | `dunning.started` | Dunning attempt begins after subscription enters `on_hold` | Monitor dunning progress |
357
+ | `dunning.recovered` | Customer updates payment method and charge succeeds | Reactivate subscription, send confirmation |
358
+
359
+ ---
360
+
361
+ ## Production-Grade Handler Pattern
362
+
363
+ 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:
276
364
 
277
365
  ```typescript
366
+ import DodoPayments from 'dodopayments';
278
367
  import express from 'express';
279
- import crypto from 'crypto';
368
+ import { Queue, Worker } from 'bullmq';
369
+
370
+ // BullMQ is illustrative; use your preferred durable async queue.
280
371
 
281
372
  const app = express();
282
- const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;
283
-
284
- // Use raw body for signature verification
285
- app.post('/webhooks/dodo',
286
- express.raw({ type: 'application/json' }),
287
- async (req, res) => {
288
- const signature = req.headers['webhook-signature'] as string;
289
- const timestamp = req.headers['webhook-timestamp'] as string;
290
- const payload = req.body.toString();
373
+ app.use(express.raw({ type: 'application/json' }));
291
374
 
292
- // Verify signature
293
- const signedPayload = `${timestamp}.${payload}`;
294
- const expectedSig = crypto
295
- .createHmac('sha256', WEBHOOK_SECRET)
296
- .update(signedPayload)
297
- .digest('base64');
298
-
299
- const providedSig = signature?.split(',')[1];
300
-
301
- if (!providedSig || !crypto.timingSafeEqual(
302
- Buffer.from(expectedSig),
303
- Buffer.from(providedSig)
304
- )) {
305
- return res.status(401).json({ error: 'Invalid signature' });
306
- }
375
+ // `environment` is a narrow union, but env vars are `string | undefined`.
376
+ // Narrow explicitly rather than casting, and default to test mode so a missing
377
+ // variable can never accidentally hit live.
378
+ const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
307
379
 
308
- const event = JSON.parse(payload);
380
+ const client = new DodoPayments({
381
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY,
382
+ environment,
383
+ webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
384
+ });
385
+
386
+ type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
387
+ type WebhookJob = { event: WebhookEvent; webhookId: string };
388
+
389
+ const connection = {
390
+ host: process.env.REDIS_HOST ?? '127.0.0.1',
391
+ port: Number(process.env.REDIS_PORT ?? '6379'),
392
+ };
393
+ const eventQueue = new Queue<WebhookJob>('webhook-events', { connection });
394
+
395
+ app.post('/webhook', async (req, res) => {
396
+ let unwrapped: WebhookEvent;
397
+
398
+ try {
399
+ unwrapped = client.webhooks.unwrap(req.body.toString(), {
400
+ headers: {
401
+ 'webhook-id': req.headers['webhook-id'] as string,
402
+ 'webhook-signature': req.headers['webhook-signature'] as string,
403
+ 'webhook-timestamp': req.headers['webhook-timestamp'] as string,
404
+ },
405
+ });
406
+ } catch (error) {
407
+ return res.status(401).json({ error: 'Invalid signature' });
408
+ }
409
+
410
+ // Durably enqueue before acknowledging, keyed by webhook-id for idempotency
411
+ const webhookId = req.headers['webhook-id'] as string;
412
+ try {
413
+ await eventQueue.add(
414
+ `process-${unwrapped.type}`,
415
+ { event: unwrapped, webhookId },
416
+ {
417
+ jobId: webhookId, // Prevents duplicate queue entries
418
+ attempts: 8,
419
+ backoff: { type: 'exponential', delay: 1000 },
420
+ }
421
+ );
422
+ return res.json({ received: true });
423
+ } catch (error) {
424
+ console.error('Failed to persist webhook', error);
425
+ return res.status(503).json({ error: 'Webhook persistence failed' });
426
+ }
427
+ });
428
+
429
+ // Async worker. webhookLog.webhookId must have a unique constraint.
430
+ const worker = new Worker<WebhookJob>(
431
+ 'webhook-events',
432
+ async (job) => {
433
+ const { event, webhookId } = job.data;
434
+
435
+ await db.$transaction(async (tx) => {
436
+ const claim = await tx.webhookLog.createMany({
437
+ data: [{ webhookId, eventType: event.type }],
438
+ skipDuplicates: true,
439
+ });
440
+ if (claim.count === 0) return;
309
441
 
310
- // Process event
311
- try {
312
442
  switch (event.type) {
313
443
  case 'payment.succeeded':
314
- await processPayment(event.data);
444
+ await handlePaymentSucceeded(event.data, tx);
315
445
  break;
316
446
  case 'subscription.active':
317
- await activateSubscription(event.data);
447
+ await handleSubscriptionActive(event.data, tx);
318
448
  break;
319
449
  // ... handle other events
320
450
  }
321
- res.json({ received: true });
322
- } catch (error) {
323
- console.error('Webhook processing error:', error);
324
- res.status(500).json({ error: 'Processing failed' });
325
- }
326
- }
451
+ });
452
+ },
453
+ { connection }
327
454
  );
328
455
  ```
329
456
 
330
- ### Python (FastAPI)
457
+ 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.
331
458
 
332
- ```python
333
- from fastapi import FastAPI, Request, HTTPException
334
- import hmac
335
- import hashlib
336
- import base64
337
- import time
459
+ ---
338
460
 
339
- app = FastAPI()
340
- WEBHOOK_SECRET = os.environ["DODO_PAYMENTS_WEBHOOK_SECRET"]
341
-
342
- def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
343
- signed_payload = f"{timestamp}.{payload.decode()}"
344
- expected_sig = base64.b64encode(
345
- hmac.new(
346
- WEBHOOK_SECRET.encode(),
347
- signed_payload.encode(),
348
- hashlib.sha256
349
- ).digest()
350
- ).decode()
351
-
352
- provided_sig = signature.split(',')[1] if ',' in signature else ''
353
- return hmac.compare_digest(expected_sig, provided_sig)
461
+ ## Delivery Semantics
354
462
 
355
- @app.post("/webhooks/dodo")
356
- async def handle_webhook(request: Request):
357
- body = await request.body()
358
- signature = request.headers.get("webhook-signature", "")
359
- timestamp = request.headers.get("webhook-timestamp", "")
360
-
361
- if not verify_signature(body, signature, timestamp):
362
- raise HTTPException(status_code=401, detail="Invalid signature")
363
-
364
- # Check timestamp freshness
365
- event_time = int(timestamp)
366
- if abs(time.time() - event_time) > 300:
367
- raise HTTPException(status_code=401, detail="Timestamp too old")
368
-
369
- event = json.loads(body)
370
-
371
- if event["type"] == "payment.succeeded":
372
- await handle_payment_succeeded(event["data"])
373
- elif event["type"] == "subscription.active":
374
- await handle_subscription_active(event["data"])
375
- # ... handle other events
376
-
377
- return {"received": True}
463
+ Understand how Dodo delivers webhooks:
464
+
465
+ | Property | Behavior |
466
+ |----------|----------|
467
+ | **Timeout** | 15 seconds for connection and read |
468
+ | **Success** | Any `2xx` response acknowledges delivery. Return `200` immediately after durably recording the event. |
469
+ | **Failure** | Any non-`2xx` response triggers a retry. |
470
+ | **Retries** | Eight attempts: immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h |
471
+ | **Idempotency** | Use `webhook-id` to detect and skip duplicates |
472
+ | **Ordering** | No guarantee. Events can arrive out of order. |
473
+ | **Payload freshness** | Delivery contains the latest resource state at delivery time |
474
+ | **Transport** | Use HTTPS in production |
475
+
476
+ ---
477
+
478
+ ## Framework Adaptor Shortcuts
479
+
480
+ If you use a supported framework, use the official adaptor package for built-in webhook handling:
481
+
482
+ | Framework | Package | Webhook handler |
483
+ |-----------|---------|---|
484
+ | Next.js | `@dodopayments/nextjs` | `Webhooks({ webhookKey, onPayload })` |
485
+ | Nuxt | `@dodopayments/nuxt` | `Webhooks({ webhookKey, onPayload })` |
486
+ | Express | `@dodopayments/express` | Middleware with raw-body parser |
487
+ | Fastify | `@dodopayments/fastify` | `Webhooks({ webhookKey, onPayload })` |
488
+ | Hono | `@dodopayments/hono` | `Webhooks({ webhookKey, onPayload })` |
489
+ | Astro | `@dodopayments/astro` | `Webhooks({ webhookKey, onPayload })` |
490
+ | SvelteKit | `@dodopayments/sveltekit` | `Webhooks({ webhookKey, onPayload })` |
491
+ | Remix | `@dodopayments/remix` | `Webhooks({ webhookKey, onPayload })` |
492
+ | TanStack Start | `@dodopayments/tanstack` | `Webhooks({ webhookKey, onPayload })` |
493
+ | Bun | `@dodopayments/bun` | `Webhooks({ webhookKey, onPayload })` |
494
+ | Better Auth | `@dodopayments/better-auth` | Plugin with `webhooks({ webhookKey, onPayload })` |
495
+ | Convex | `@dodopayments/convex` | Component with verified HTTP handler |
496
+
497
+ **Next.js example:**
498
+
499
+ 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.
500
+
501
+ ```typescript
502
+ // app/api/webhook/dodo-payments/route.ts
503
+ import { Webhooks } from "@dodopayments/nextjs";
504
+
505
+ export const POST = Webhooks({
506
+ webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
507
+ onPayload: async (payload) => {
508
+ // payload is already verified
509
+ console.log(`Received ${payload.type}`);
510
+
511
+ if (payload.type !== 'payment.succeeded') return;
512
+
513
+ // webhookLog.webhookId must have a unique constraint. If fulfillment
514
+ // throws, the claim rolls back and Dodo's redelivery can retry it.
515
+ await db.$transaction(async (tx) => {
516
+ const claim = await tx.webhookLog.createMany({
517
+ data: [{
518
+ webhookId: payload.data.payment_id,
519
+ eventType: payload.type,
520
+ }],
521
+ skipDuplicates: true,
522
+ });
523
+ if (claim.count === 0) return;
524
+
525
+ await handlePaymentSucceeded(payload.data, tx);
526
+ });
527
+ },
528
+ });
378
529
  ```
379
530
 
380
- ### Go
531
+ Handle `subscription.active` only in a handler that exposes `webhook-id`, then commit that claim and the entitlement changes in the same transaction.
381
532
 
382
- ```go
383
- package main
533
+ ---
384
534
 
385
- import (
386
- "crypto/hmac"
387
- "crypto/sha256"
388
- "encoding/base64"
389
- "encoding/json"
390
- "io"
391
- "net/http"
392
- "os"
393
- "strconv"
394
- "strings"
395
- "time"
396
- )
535
+ ## Local Testing
397
536
 
398
- var webhookSecret = os.Getenv("DODO_PAYMENTS_WEBHOOK_SECRET")
537
+ ### Dashboard test tool
399
538
 
400
- func verifySignature(payload []byte, signature, timestamp string) bool {
401
- signedPayload := timestamp + "." + string(payload)
402
-
403
- mac := hmac.New(sha256.New, []byte(webhookSecret))
404
- mac.Write([]byte(signedPayload))
405
- expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
406
-
407
- parts := strings.Split(signature, ",")
408
- if len(parts) < 2 {
409
- return false
410
- }
411
-
412
- return hmac.Equal([]byte(expectedSig), []byte(parts[1]))
413
- }
539
+ 1. Go to **Developer → Webhooks → [your endpoint] → Testing**
540
+ 2. Select an event type
541
+ 3. Click **Send Example**
542
+ 4. Verify your endpoint returns `200` and the signature verifies
414
543
 
415
- func webhookHandler(w http.ResponseWriter, r *http.Request) {
416
- body, _ := io.ReadAll(r.Body)
417
- signature := r.Header.Get("webhook-signature")
418
- timestamp := r.Header.Get("webhook-timestamp")
419
-
420
- if !verifySignature(body, signature, timestamp) {
421
- http.Error(w, "Invalid signature", http.StatusUnauthorized)
422
- return
423
- }
424
-
425
- // Check timestamp
426
- ts, _ := strconv.ParseInt(timestamp, 10, 64)
427
- if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
428
- http.Error(w, "Timestamp too old", http.StatusUnauthorized)
429
- return
430
- }
431
-
432
- var event map[string]interface{}
433
- json.Unmarshal(body, &event)
434
-
435
- switch event["type"] {
436
- case "payment.succeeded":
437
- handlePaymentSucceeded(event["data"].(map[string]interface{}))
438
- case "subscription.active":
439
- handleSubscriptionActive(event["data"].(map[string]interface{}))
440
- }
441
-
442
- w.Header().Set("Content-Type", "application/json")
443
- json.NewEncoder(w).Encode(map[string]bool{"received": true})
444
- }
544
+ ### CLI: Live forwarding
545
+
546
+ Forward real test-mode events to localhost:
547
+
548
+ ```bash
549
+ dodo wh listen http://localhost:3000/webhook
550
+ ```
551
+
552
+ 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.
553
+
554
+ ### CLI: Unsigned mock events
555
+
556
+ Generate realistic unsigned payloads for testing without signature verification:
557
+
558
+ ```bash
559
+ dodo wh trigger payment.success http://localhost:3000/webhook
560
+ ```
561
+
562
+ Use `unsafeUnwrap()` only for these unsigned payloads. Both arguments are required in direct mode.
563
+
564
+ ### Tunnel
565
+
566
+ Expose localhost with ngrok and register the HTTPS URL in the dashboard:
567
+
568
+ ```bash
569
+ ngrok http 3000
570
+ # Register https://xxxx.ngrok.io/webhook in the dashboard
445
571
  ```
446
572
 
447
573
  ---
448
574
 
449
- ## Best Practices
575
+ ## Common Mistakes
450
576
 
451
- ### 1. Always Verify Signatures
452
- Never process webhooks without signature verification to prevent spoofing.
577
+ ### 1. Signing only the payload or `timestamp.payload`
453
578
 
454
- ### 2. Implement Idempotency
455
- Use `webhook-id` header to prevent duplicate processing:
579
+ **Wrong:**
456
580
  ```typescript
457
- const processedIds = new Set<string>();
581
+ const signed = crypto.createHmac('sha256', secret)
582
+ .update(payload)
583
+ .digest('base64');
584
+
585
+ // or
586
+ const signed = crypto.createHmac('sha256', secret)
587
+ .update(`${timestamp}.${payload}`)
588
+ .digest('base64');
589
+ ```
458
590
 
459
- if (processedIds.has(webhookId)) {
460
- return NextResponse.json({ received: true }); // Already processed
461
- }
462
- processedIds.add(webhookId);
591
+ **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.
592
+
593
+ ### 2. Re-serializing the parsed body
594
+
595
+ **Wrong:**
596
+ ```typescript
597
+ const body = await req.json();
598
+ const signed = JSON.stringify(body); // Reordered, reformatted, breaks signature
599
+ ```
600
+
601
+ **Correct:** Always use the raw bytes:
602
+ ```typescript
603
+ const body = await req.text(); // or req.body.toString() in Express
604
+ // Pass body directly to unwrap() or webhook.verify()
463
605
  ```
464
606
 
465
- ### 3. Respond Quickly
466
- Return 200 immediately, process asynchronously if needed:
607
+ ### 3. Naive comma-splitting of the signature header
608
+
609
+ **Wrong:**
467
610
  ```typescript
468
- // Queue for async processing
469
- await queue.add('process-webhook', event);
470
- return NextResponse.json({ received: true });
611
+ const sig = signature.split(',')[1]; // Assumes exactly one comma
471
612
  ```
472
613
 
473
- ### 4. Handle Retries
474
- Dodo Payments retries failed webhooks. Design handlers to be idempotent.
614
+ The header is versioned and can contain multiple values. Use the SDK helper.
615
+
616
+ ### 4. `timingSafeEqual` throwing on length mismatch
475
617
 
476
- ### 5. Log Everything
477
- Keep detailed logs for debugging:
618
+ **Wrong:**
478
619
  ```typescript
479
- console.log(`[Webhook] ${event.type} - ${webhookId}`, {
480
- timestamp: event.timestamp,
481
- data: event.data
620
+ crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
621
+ // Throws if lengths differ; doesn't return false
622
+ ```
623
+
624
+ **Correct:** Use the SDK helper, which handles this safely.
625
+
626
+ ### 5. Granting access from `return_url` instead of verified webhooks
627
+
628
+ **Wrong:**
629
+ ```typescript
630
+ // User visits return_url after checkout; you grant access
631
+ app.get('/checkout/return', (req, res) => {
632
+ grantAccess(req.query.customer_id); // Unverified!
482
633
  });
483
634
  ```
484
635
 
485
- ---
636
+ **Correct:** Grant access only after receiving and verifying a webhook:
637
+ ```typescript
638
+ const event = client.webhooks.unwrap(rawBody, { headers });
639
+
640
+ if (event.type === 'payment.succeeded') {
641
+ grantAccess(event.data.customer.customer_id);
642
+ }
643
+ ```
486
644
 
487
- ## Local Development
645
+ ### 6. Doing slow work before responding 2xx
488
646
 
489
- ### Using ngrok
490
- ```bash
491
- # Start ngrok tunnel
492
- ngrok http 3000
647
+ **Wrong:**
648
+ ```typescript
649
+ app.post('/webhook', async (req, res) => {
650
+ const unwrapped = client.webhooks.unwrap(...);
651
+
652
+ // Slow database writes, email sends, etc.
653
+ await db.payment.create(...);
654
+ await sendEmail(...);
655
+
656
+ res.json({ received: true }); // Dodo times out after 15s
657
+ });
658
+ ```
493
659
 
494
- # Use the ngrok URL as your webhook endpoint
495
- # https://xxxx.ngrok.io/api/webhooks/dodo
660
+ **Correct:** Durably enqueue first, then respond immediately. Return non-`2xx` if persistence fails so Dodo retries:
661
+ ```typescript
662
+ app.post('/webhook', async (req, res) => {
663
+ const unwrapped = client.webhooks.unwrap(...);
664
+
665
+ try {
666
+ await queue.add('process-event', unwrapped); // Durable write
667
+ return res.json({ received: true });
668
+ } catch (error) {
669
+ console.error('Failed to persist webhook', error);
670
+ return res.status(503).json({ error: 'Webhook persistence failed' });
671
+ }
672
+ });
496
673
  ```
497
674
 
498
- ### Testing Webhooks
499
- You can trigger test webhooks from the Dodo Payments dashboard:
500
- 1. Go to Developer → Webhooks
501
- 2. Select your webhook
502
- 3. Click "Send Test Event"
675
+ ### 7. Acknowledging before durable persistence
503
676
 
504
- ---
677
+ **Wrong:**
678
+ ```typescript
679
+ res.json({ received: true });
680
+ await queue.add('process-event', unwrapped); // A crash can lose an acknowledged event
681
+ ```
682
+
683
+ **Correct:** Verify the signature, durably insert or enqueue the event, and only then return `2xx`. If persistence fails, return non-`2xx` so Dodo retries.
505
684
 
506
- ## Troubleshooting
685
+ ### 8. Committing an idempotency claim before fulfillment
686
+
687
+ **Wrong:**
688
+ ```typescript
689
+ const claim = await db.webhookLog.createMany({
690
+ data: [{ webhookId }],
691
+ skipDuplicates: true,
692
+ });
693
+ if (claim.count === 0) return;
694
+
695
+ await grantSubscriptionEntitlements(event.data); // A failure leaves the claim behind
696
+ ```
507
697
 
508
- ### Signature Verification Failing
509
- - Ensure you're using the raw request body
510
- - Check webhook secret is correct
511
- - Verify timestamp format (Unix seconds)
698
+ The retry sees the existing claim and skips fulfillment, permanently dropping the event.
512
699
 
513
- ### Not Receiving Webhooks
514
- - Check endpoint is publicly accessible
515
- - Verify webhook is enabled in dashboard
516
- - Check server logs for errors
700
+ **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:
701
+ ```typescript
702
+ const event = client.webhooks.unwrap(rawBody, { headers });
703
+
704
+ await db.$transaction(async (tx) => {
705
+ const claim = await tx.webhookLog.createMany({
706
+ data: [{ webhookId, eventType: event.type }],
707
+ skipDuplicates: true,
708
+ });
709
+ if (claim.count === 0) return;
710
+
711
+ if (event.type === 'subscription.active') {
712
+ await grantSubscriptionEntitlements(event.data, tx);
713
+ }
714
+ });
715
+ ```
517
716
 
518
- ### Duplicate Events
519
- - Implement idempotency using webhook-id
520
- - Store processed event IDs in database
717
+ Use a transactional outbox for email or other external effects that must follow the database commit.
521
718
 
522
719
  ---
523
720
 
524
721
  ## Resources
525
722
 
526
723
  - [Webhook Documentation](https://docs.dodopayments.com/developer-resources/webhooks)
527
- - [Event Reference](https://docs.dodopayments.com/developer-resources/webhooks/intents/webhook-events-guide)
724
+ - [Event Catalog](https://docs.dodopayments.com/developer-resources/webhooks/intents/webhook-events-guide)
528
725
  - [Standard Webhooks Spec](https://standardwebhooks.com/)
529
- - [Credit Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/credit)
726
+ - [Framework Adaptors](https://docs.dodopayments.com/developer-resources/framework-adaptors)
727
+ - [CLI Reference](https://docs.dodopayments.com/developer-resources/sdks/cli)