@dodopayments/opencode-plugin 0.3.3 → 0.5.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.
- package/.mcp.json +1 -1
- package/README.md +152 -42
- package/opencode-plugin/README.md +20 -6
- package/opencode-plugin/index.js +27 -1
- package/package.json +5 -5
- package/skills/better-auth-integration/SKILL.md +214 -0
- package/skills/billing-sdk/SKILL.md +357 -370
- package/skills/checkout-integration/SKILL.md +580 -329
- package/skills/credit-based-billing/SKILL.md +448 -775
- package/skills/customer-management/SKILL.md +281 -0
- package/skills/discounts-and-promotions/SKILL.md +464 -0
- package/skills/dodo-best-practices/SKILL.md +471 -0
- package/skills/framework-adapters/SKILL.md +644 -0
- package/skills/license-keys/SKILL.md +324 -199
- package/skills/localized-pricing/SKILL.md +230 -0
- package/skills/mobile-checkout/SKILL.md +521 -0
- package/skills/product-catalog-management/SKILL.md +311 -0
- package/skills/refunds-and-disputes/SKILL.md +333 -0
- package/skills/subscription-integration/SKILL.md +323 -404
- package/skills/testing-and-go-live/SKILL.md +335 -0
- package/skills/usage-based-billing/SKILL.md +339 -479
- package/skills/webhook-integration/SKILL.md +600 -402
- package/skills/best-practices/SKILL.md +0 -315
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
52
|
+
## Webhook Headers and Payload
|
|
73
53
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
webhook-id
|
|
79
|
-
webhook-signature
|
|
80
|
-
webhook-timestamp
|
|
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": "
|
|
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
|
-
##
|
|
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
|
-
|
|
94
|
+
**TypeScript/Node:**
|
|
109
95
|
|
|
110
96
|
```typescript
|
|
111
|
-
|
|
112
|
-
import
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
189
|
-
}
|
|
133
|
+
**Python:**
|
|
190
134
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
###
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
444
|
+
await handlePaymentSucceeded(event.data, tx);
|
|
315
445
|
break;
|
|
316
446
|
case 'subscription.active':
|
|
317
|
-
await
|
|
447
|
+
await handleSubscriptionActive(event.data, tx);
|
|
318
448
|
break;
|
|
319
449
|
// ... handle other events
|
|
320
450
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
res.status(500).json({ error: 'Processing failed' });
|
|
325
|
-
}
|
|
326
|
-
}
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
{ connection }
|
|
327
454
|
);
|
|
328
455
|
```
|
|
329
456
|
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
from fastapi import FastAPI, Request, HTTPException
|
|
334
|
-
import hmac
|
|
335
|
-
import hashlib
|
|
336
|
-
import base64
|
|
337
|
-
import time
|
|
459
|
+
---
|
|
338
460
|
|
|
339
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
package main
|
|
533
|
+
---
|
|
384
534
|
|
|
385
|
-
|
|
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
|
-
|
|
537
|
+
### Dashboard test tool
|
|
399
538
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
##
|
|
575
|
+
## Common Mistakes
|
|
450
576
|
|
|
451
|
-
### 1.
|
|
452
|
-
Never process webhooks without signature verification to prevent spoofing.
|
|
577
|
+
### 1. Signing only the payload or `timestamp.payload`
|
|
453
578
|
|
|
454
|
-
|
|
455
|
-
Use `webhook-id` header to prevent duplicate processing:
|
|
579
|
+
**Wrong:**
|
|
456
580
|
```typescript
|
|
457
|
-
const
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
466
|
-
|
|
607
|
+
### 3. Naive comma-splitting of the signature header
|
|
608
|
+
|
|
609
|
+
**Wrong:**
|
|
467
610
|
```typescript
|
|
468
|
-
//
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
Keep detailed logs for debugging:
|
|
618
|
+
**Wrong:**
|
|
478
619
|
```typescript
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
645
|
+
### 6. Doing slow work before responding 2xx
|
|
488
646
|
|
|
489
|
-
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
|
724
|
+
- [Event Catalog](https://docs.dodopayments.com/developer-resources/webhooks/intents/webhook-events-guide)
|
|
528
725
|
- [Standard Webhooks Spec](https://standardwebhooks.com/)
|
|
529
|
-
- [
|
|
726
|
+
- [Framework Adaptors](https://docs.dodopayments.com/developer-resources/framework-adaptors)
|
|
727
|
+
- [CLI Reference](https://docs.dodopayments.com/developer-resources/sdks/cli)
|