@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.
@@ -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)