@dodopayments/opencode-plugin 0.2.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/opencode-plugin/README.md +42 -0
- package/opencode-plugin/index.js +30 -0
- package/opencode.json +28 -0
- package/package.json +53 -0
- package/skills/best-practices/SKILL.md +315 -0
- package/skills/billing-sdk/SKILL.md +501 -0
- package/skills/checkout-integration/SKILL.md +599 -0
- package/skills/credit-based-billing/SKILL.md +942 -0
- package/skills/license-keys/SKILL.md +591 -0
- package/skills/subscription-integration/SKILL.md +571 -0
- package/skills/usage-based-billing/SKILL.md +680 -0
- package/skills/webhook-integration/SKILL.md +529 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webhook-integration
|
|
3
|
+
description: Complete guide for setting up and handling Dodo Payments webhooks for real-time payment event notifications.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dodo Payments Webhook Integration
|
|
7
|
+
|
|
8
|
+
**Reference: [docs.dodopayments.com/developer-resources/webhooks](https://docs.dodopayments.com/developer-resources/webhooks)**
|
|
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.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Setup
|
|
15
|
+
|
|
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
|
|
22
|
+
|
|
23
|
+
### 2. Environment Variables
|
|
24
|
+
```bash
|
|
25
|
+
DODO_PAYMENTS_WEBHOOK_SECRET=your_webhook_secret_here
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
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
|
+
---
|
|
71
|
+
|
|
72
|
+
## Webhook Payload Structure
|
|
73
|
+
|
|
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
|
+
```
|
|
82
|
+
|
|
83
|
+
### Payload Format
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"business_id": "bus_xxxxx",
|
|
87
|
+
"type": "payment.succeeded",
|
|
88
|
+
"timestamp": "2024-01-01T12:00:00Z",
|
|
89
|
+
"data": {
|
|
90
|
+
"payload_type": "Payment",
|
|
91
|
+
"payment_id": "pay_xxxxx",
|
|
92
|
+
"total_amount": 2999,
|
|
93
|
+
"currency": "USD",
|
|
94
|
+
"customer": {
|
|
95
|
+
"customer_id": "cust_xxxxx",
|
|
96
|
+
"email": "customer@example.com",
|
|
97
|
+
"name": "John Doe"
|
|
98
|
+
}
|
|
99
|
+
// ... additional event-specific fields
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Implementation Examples
|
|
107
|
+
|
|
108
|
+
### Next.js (App Router)
|
|
109
|
+
|
|
110
|
+
```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
|
+
}
|
|
132
|
+
|
|
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');
|
|
138
|
+
|
|
139
|
+
// Verify signature
|
|
140
|
+
if (!verifySignature(body, signature, timestamp)) {
|
|
141
|
+
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
|
|
142
|
+
}
|
|
143
|
+
|
|
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
|
+
}
|
|
149
|
+
|
|
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}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return NextResponse.json({ received: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
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
|
+
}
|
|
199
|
+
|
|
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
|
+
}
|
|
208
|
+
|
|
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
|
+
}
|
|
217
|
+
|
|
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
|
+
}
|
|
225
|
+
|
|
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
|
+
}
|
|
233
|
+
|
|
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
|
+
}
|
|
241
|
+
|
|
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)}...`);
|
|
248
|
+
}
|
|
249
|
+
|
|
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}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
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
|
+
}
|
|
265
|
+
|
|
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
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Express.js
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
import express from 'express';
|
|
279
|
+
import crypto from 'crypto';
|
|
280
|
+
|
|
281
|
+
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();
|
|
291
|
+
|
|
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
|
+
}
|
|
307
|
+
|
|
308
|
+
const event = JSON.parse(payload);
|
|
309
|
+
|
|
310
|
+
// Process event
|
|
311
|
+
try {
|
|
312
|
+
switch (event.type) {
|
|
313
|
+
case 'payment.succeeded':
|
|
314
|
+
await processPayment(event.data);
|
|
315
|
+
break;
|
|
316
|
+
case 'subscription.active':
|
|
317
|
+
await activateSubscription(event.data);
|
|
318
|
+
break;
|
|
319
|
+
// ... handle other events
|
|
320
|
+
}
|
|
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
|
+
}
|
|
327
|
+
);
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Python (FastAPI)
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
334
|
+
import hmac
|
|
335
|
+
import hashlib
|
|
336
|
+
import base64
|
|
337
|
+
import time
|
|
338
|
+
|
|
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)
|
|
354
|
+
|
|
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}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Go
|
|
381
|
+
|
|
382
|
+
```go
|
|
383
|
+
package main
|
|
384
|
+
|
|
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
|
+
)
|
|
397
|
+
|
|
398
|
+
var webhookSecret = os.Getenv("DODO_PAYMENTS_WEBHOOK_SECRET")
|
|
399
|
+
|
|
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
|
+
}
|
|
414
|
+
|
|
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
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Best Practices
|
|
450
|
+
|
|
451
|
+
### 1. Always Verify Signatures
|
|
452
|
+
Never process webhooks without signature verification to prevent spoofing.
|
|
453
|
+
|
|
454
|
+
### 2. Implement Idempotency
|
|
455
|
+
Use `webhook-id` header to prevent duplicate processing:
|
|
456
|
+
```typescript
|
|
457
|
+
const processedIds = new Set<string>();
|
|
458
|
+
|
|
459
|
+
if (processedIds.has(webhookId)) {
|
|
460
|
+
return NextResponse.json({ received: true }); // Already processed
|
|
461
|
+
}
|
|
462
|
+
processedIds.add(webhookId);
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### 3. Respond Quickly
|
|
466
|
+
Return 200 immediately, process asynchronously if needed:
|
|
467
|
+
```typescript
|
|
468
|
+
// Queue for async processing
|
|
469
|
+
await queue.add('process-webhook', event);
|
|
470
|
+
return NextResponse.json({ received: true });
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### 4. Handle Retries
|
|
474
|
+
Dodo Payments retries failed webhooks. Design handlers to be idempotent.
|
|
475
|
+
|
|
476
|
+
### 5. Log Everything
|
|
477
|
+
Keep detailed logs for debugging:
|
|
478
|
+
```typescript
|
|
479
|
+
console.log(`[Webhook] ${event.type} - ${webhookId}`, {
|
|
480
|
+
timestamp: event.timestamp,
|
|
481
|
+
data: event.data
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Local Development
|
|
488
|
+
|
|
489
|
+
### Using ngrok
|
|
490
|
+
```bash
|
|
491
|
+
# Start ngrok tunnel
|
|
492
|
+
ngrok http 3000
|
|
493
|
+
|
|
494
|
+
# Use the ngrok URL as your webhook endpoint
|
|
495
|
+
# https://xxxx.ngrok.io/api/webhooks/dodo
|
|
496
|
+
```
|
|
497
|
+
|
|
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"
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Troubleshooting
|
|
507
|
+
|
|
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)
|
|
512
|
+
|
|
513
|
+
### Not Receiving Webhooks
|
|
514
|
+
- Check endpoint is publicly accessible
|
|
515
|
+
- Verify webhook is enabled in dashboard
|
|
516
|
+
- Check server logs for errors
|
|
517
|
+
|
|
518
|
+
### Duplicate Events
|
|
519
|
+
- Implement idempotency using webhook-id
|
|
520
|
+
- Store processed event IDs in database
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Resources
|
|
525
|
+
|
|
526
|
+
- [Webhook Documentation](https://docs.dodopayments.com/developer-resources/webhooks)
|
|
527
|
+
- [Event Reference](https://docs.dodopayments.com/developer-resources/webhooks/intents/webhook-events-guide)
|
|
528
|
+
- [Standard Webhooks Spec](https://standardwebhooks.com/)
|
|
529
|
+
- [Credit Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/credit)
|