webhook-integration

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Dodo Payments Webhook Integration

Dodo Payments Webhook 集成

Webhooks provide real-time notifications when payment events occur. Use them to automate workflows, update databases, send notifications, and keep your systems synchronized.

Webhook 可在支付事件发生时提供实时通知。您可以使用它们来自动化工作流、更新数据库、发送通知并保持系统同步。

Quick Setup

快速设置

1. Configure Webhook in Dashboard

1. 在控制台配置 Webhook

  1. Go to Dashboard → Developer → Webhooks
  2. Click "Create Webhook"
  3. Enter your endpoint URL
  4. Select events to subscribe to
  5. Copy the webhook secret
  1. 进入控制台 → 开发者 → Webhook
  2. 点击“创建 Webhook”
  3. 输入您的端点 URL
  4. 选择要订阅的事件
  5. 复制 Webhook 密钥

2. Environment Variables

2. 环境变量

bash
DODO_PAYMENTS_WEBHOOK_SECRET=your_webhook_secret_here

bash
DODO_PAYMENTS_WEBHOOK_SECRET=your_webhook_secret_here

Webhook Events

Webhook 事件

Payment Events

支付事件

EventDescription
payment.succeeded
Payment completed successfully
payment.failed
Payment attempt failed
payment.processing
Payment is being processed
payment.cancelled
Payment was cancelled
事件描述
payment.succeeded
支付成功完成
payment.failed
支付尝试失败
payment.processing
支付处理中
payment.cancelled
支付已取消

Subscription Events

订阅事件

EventDescription
subscription.active
Subscription is now active
subscription.updated
Subscription details changed
subscription.on_hold
Subscription on hold (failed renewal)
subscription.renewed
Subscription renewed successfully
subscription.plan_changed
Plan upgraded/downgraded
subscription.cancelled
Subscription cancelled
subscription.failed
Subscription creation failed
subscription.expired
Subscription term ended
事件描述
subscription.active
订阅已激活
subscription.updated
订阅详情已更改
subscription.on_hold
订阅暂停(续费失败)
subscription.renewed
订阅续费成功
subscription.plan_changed
套餐已升级/降级
subscription.cancelled
订阅已取消
subscription.failed
订阅创建失败
subscription.expired
订阅期限已结束

Other Events

其他事件

EventDescription
refund.succeeded
Refund processed successfully
dispute.opened
New dispute received
license_key.created
License key generated

事件描述
refund.succeeded
退款处理成功
dispute.opened
收到新的争议
license_key.created
生成许可证密钥

Webhook Payload Structure

Webhook 负载结构

Request Headers

请求头

http
POST /your-webhook-url
Content-Type: application/json
webhook-id: evt_xxxxx
webhook-signature: v1,signature_here
webhook-timestamp: 1234567890
http
POST /your-webhook-url
Content-Type: application/json
webhook-id: evt_xxxxx
webhook-signature: v1,signature_here
webhook-timestamp: 1234567890

Payload Format

负载格式

json
{
  "business_id": "bus_xxxxx",
  "type": "payment.succeeded",
  "timestamp": "2024-01-01T12:00:00Z",
  "data": {
    "payload_type": "Payment",
    "payment_id": "pay_xxxxx",
    "total_amount": 2999,
    "currency": "USD",
    "customer": {
      "customer_id": "cust_xxxxx",
      "email": "customer@example.com",
      "name": "John Doe"
    }
    // ... additional event-specific fields
  }
}

json
{
  "business_id": "bus_xxxxx",
  "type": "payment.succeeded",
  "timestamp": "2024-01-01T12:00:00Z",
  "data": {
    "payload_type": "Payment",
    "payment_id": "pay_xxxxx",
    "total_amount": 2999,
    "currency": "USD",
    "customer": {
      "customer_id": "cust_xxxxx",
      "email": "customer@example.com",
      "name": "John Doe"
    }
    // ... 其他事件特定字段
  }
}

Implementation Examples

实现示例

Next.js (App Router)

Next.js(App Router)

typescript
// app/api/webhooks/dodo/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;

function verifySignature(payload: string, signature: string, timestamp: string): boolean {
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('base64');
  
  // Extract signature from "v1,signature" format
  const providedSig = signature.split(',')[1];
  
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(providedSig || '')
  );
}

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get('webhook-signature') || '';
  const timestamp = req.headers.get('webhook-timestamp') || '';
  const webhookId = req.headers.get('webhook-id');

  // Verify signature
  if (!verifySignature(body, signature, timestamp)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Check timestamp to prevent replay attacks (5 minute tolerance)
  const eventTime = parseInt(timestamp) * 1000;
  if (Math.abs(Date.now() - eventTime) > 300000) {
    return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 });
  }

  const event = JSON.parse(body);

  // Handle events
  switch (event.type) {
    case 'payment.succeeded':
      await handlePaymentSucceeded(event.data);
      break;
    case 'payment.failed':
      await handlePaymentFailed(event.data);
      break;
    case 'subscription.active':
      await handleSubscriptionActive(event.data);
      break;
    case 'subscription.cancelled':
      await handleSubscriptionCancelled(event.data);
      break;
    case 'refund.succeeded':
      await handleRefundSucceeded(event.data);
      break;
    case 'dispute.opened':
      await handleDisputeOpened(event.data);
      break;
    case 'license_key.created':
      await handleLicenseKeyCreated(event.data);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

async function handlePaymentSucceeded(data: any) {
  const { payment_id, customer, total_amount, product_id, subscription_id } = data;
  
  // Update database
  // Send confirmation email
  // Grant access to product
  console.log(`Payment ${payment_id} succeeded for ${customer.email}`);
}

async function handlePaymentFailed(data: any) {
  const { payment_id, customer, error_message } = data;
  
  // Log failure
  // Notify customer
  // Update UI state
  console.log(`Payment ${payment_id} failed: ${error_message}`);
}

async function handleSubscriptionActive(data: any) {
  const { subscription_id, customer, product_id, next_billing_date } = data;
  
  // Grant subscription access
  // Update user record
  // Send welcome email
  console.log(`Subscription ${subscription_id} activated for ${customer.email}`);
}

async function handleSubscriptionCancelled(data: any) {
  const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
  
  // Schedule access revocation
  // Send cancellation confirmation
  console.log(`Subscription ${subscription_id} cancelled`);
}

async function handleRefundSucceeded(data: any) {
  const { refund_id, payment_id, amount } = data;
  
  // Update order status
  // Revoke access if needed
  console.log(`Refund ${refund_id} processed for payment ${payment_id}`);
}

async function handleDisputeOpened(data: any) {
  const { dispute_id, payment_id, amount, dispute_status } = data;
  
  // Alert team
  // Prepare evidence
  console.log(`Dispute ${dispute_id} opened for payment ${payment_id}`);
}

async function handleLicenseKeyCreated(data: any) {
  const { id, key, product_id, customer_id, expires_at } = data;
  
  // Store license key
  // Send to customer
  console.log(`License key created: ${key.substring(0, 8)}...`);
}
typescript
// app/api/webhooks/dodo/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;

function verifySignature(payload: string, signature: string, timestamp: string): boolean {
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('base64');
  
  // Extract signature from "v1,signature" format
  const providedSig = signature.split(',')[1];
  
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(providedSig || '')
  );
}

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get('webhook-signature') || '';
  const timestamp = req.headers.get('webhook-timestamp') || '';
  const webhookId = req.headers.get('webhook-id');

  // Verify signature
  if (!verifySignature(body, signature, timestamp)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Check timestamp to prevent replay attacks (5 minute tolerance)
  const eventTime = parseInt(timestamp) * 1000;
  if (Math.abs(Date.now() - eventTime) > 300000) {
    return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 });
  }

  const event = JSON.parse(body);

  // Handle events
  switch (event.type) {
    case 'payment.succeeded':
      await handlePaymentSucceeded(event.data);
      break;
    case 'payment.failed':
      await handlePaymentFailed(event.data);
      break;
    case 'subscription.active':
      await handleSubscriptionActive(event.data);
      break;
    case 'subscription.cancelled':
      await handleSubscriptionCancelled(event.data);
      break;
    case 'refund.succeeded':
      await handleRefundSucceeded(event.data);
      break;
    case 'dispute.opened':
      await handleDisputeOpened(event.data);
      break;
    case 'license_key.created':
      await handleLicenseKeyCreated(event.data);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

async function handlePaymentSucceeded(data: any) {
  const { payment_id, customer, total_amount, product_id, subscription_id } = data;
  
  // Update database
  // Send confirmation email
  // Grant access to product
  console.log(`Payment ${payment_id} succeeded for ${customer.email}`);
}

async function handlePaymentFailed(data: any) {
  const { payment_id, customer, error_message } = data;
  
  // Log failure
  // Notify customer
  // Update UI state
  console.log(`Payment ${payment_id} failed: ${error_message}`);
}

async function handleSubscriptionActive(data: any) {
  const { subscription_id, customer, product_id, next_billing_date } = data;
  
  // Grant subscription access
  // Update user record
  // Send welcome email
  console.log(`Subscription ${subscription_id} activated for ${customer.email}`);
}

async function handleSubscriptionCancelled(data: any) {
  const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
  
  // Schedule access revocation
  // Send cancellation confirmation
  console.log(`Subscription ${subscription_id} cancelled`);
}

async function handleRefundSucceeded(data: any) {
  const { refund_id, payment_id, amount } = data;
  
  // Update order status
  // Revoke access if needed
  console.log(`Refund ${refund_id} processed for payment ${payment_id}`);
}

async function handleDisputeOpened(data: any) {
  const { dispute_id, payment_id, amount, dispute_status } = data;
  
  // Alert team
  // Prepare evidence
  console.log(`Dispute ${dispute_id} opened for payment ${payment_id}`);
}

async function handleLicenseKeyCreated(data: any) {
  const { id, key, product_id, customer_id, expires_at } = data;
  
  // Store license key
  // Send to customer
  console.log(`License key created: ${key.substring(0, 8)}...`);
}

Express.js

Express.js

typescript
import express from 'express';
import crypto from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;

// Use raw body for signature verification
app.post('/webhooks/dodo', 
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['webhook-signature'] as string;
    const timestamp = req.headers['webhook-timestamp'] as string;
    const payload = req.body.toString();

    // Verify signature
    const signedPayload = `${timestamp}.${payload}`;
    const expectedSig = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(signedPayload)
      .digest('base64');
    
    const providedSig = signature?.split(',')[1];
    
    if (!providedSig || !crypto.timingSafeEqual(
      Buffer.from(expectedSig),
      Buffer.from(providedSig)
    )) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(payload);

    // Process event
    try {
      switch (event.type) {
        case 'payment.succeeded':
          await processPayment(event.data);
          break;
        case 'subscription.active':
          await activateSubscription(event.data);
          break;
        // ... handle other events
      }
      res.json({ received: true });
    } catch (error) {
      console.error('Webhook processing error:', error);
      res.status(500).json({ error: 'Processing failed' });
    }
  }
);
typescript
import express from 'express';
import crypto from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.DODO_PAYMENTS_WEBHOOK_SECRET!;

// Use raw body for signature verification
app.post('/webhooks/dodo', 
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['webhook-signature'] as string;
    const timestamp = req.headers['webhook-timestamp'] as string;
    const payload = req.body.toString();

    // Verify signature
    const signedPayload = `${timestamp}.${payload}`;
    const expectedSig = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(signedPayload)
      .digest('base64');
    
    const providedSig = signature?.split(',')[1];
    
    if (!providedSig || !crypto.timingSafeEqual(
      Buffer.from(expectedSig),
      Buffer.from(providedSig)
    )) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(payload);

    // Process event
    try {
      switch (event.type) {
        case 'payment.succeeded':
          await processPayment(event.data);
          break;
        case 'subscription.active':
          await activateSubscription(event.data);
          break;
        // ... handle other events
      }
      res.json({ received: true });
    } catch (error) {
      console.error('Webhook processing error:', error);
      res.status(500).json({ error: 'Processing failed' });
    }
  }
);

Python (FastAPI)

Python(FastAPI)

python
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import base64
import time

app = FastAPI()
WEBHOOK_SECRET = os.environ["DODO_PAYMENTS_WEBHOOK_SECRET"]

def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
    signed_payload = f"{timestamp}.{payload.decode()}"
    expected_sig = base64.b64encode(
        hmac.new(
            WEBHOOK_SECRET.encode(),
            signed_payload.encode(),
            hashlib.sha256
        ).digest()
    ).decode()
    
    provided_sig = signature.split(',')[1] if ',' in signature else ''
    return hmac.compare_digest(expected_sig, provided_sig)

@app.post("/webhooks/dodo")
async def handle_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("webhook-signature", "")
    timestamp = request.headers.get("webhook-timestamp", "")
    
    if not verify_signature(body, signature, timestamp):
        raise HTTPException(status_code=401, detail="Invalid signature")
    
    # Check timestamp freshness
    event_time = int(timestamp)
    if abs(time.time() - event_time) > 300:
        raise HTTPException(status_code=401, detail="Timestamp too old")
    
    event = json.loads(body)
    
    if event["type"] == "payment.succeeded":
        await handle_payment_succeeded(event["data"])
    elif event["type"] == "subscription.active":
        await handle_subscription_active(event["data"])
    # ... handle other events
    
    return {"received": True}
python
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import base64
import time
import os
import json

app = FastAPI()
WEBHOOK_SECRET = os.environ["DODO_PAYMENTS_WEBHOOK_SECRET"]

def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
    signed_payload = f"{timestamp}.{payload.decode()}"
    expected_sig = base64.b64encode(
        hmac.new(
            WEBHOOK_SECRET.encode(),
            signed_payload.encode(),
            hashlib.sha256
        ).digest()
    ).decode()
    
    provided_sig = signature.split(',')[1] if ',' in signature else ''
    return hmac.compare_digest(expected_sig, provided_sig)

@app.post("/webhooks/dodo")
async def handle_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("webhook-signature", "")
    timestamp = request.headers.get("webhook-timestamp", "")
    
    if not verify_signature(body, signature, timestamp):
        raise HTTPException(status_code=401, detail="Invalid signature")
    
    # Check timestamp freshness
    event_time = int(timestamp)
    if abs(time.time() - event_time) > 300:
        raise HTTPException(status_code=401, detail="Timestamp too old")
    
    event = json.loads(body)
    
    if event["type"] == "payment.succeeded":
        await handle_payment_succeeded(event["data"])
    elif event["type"] == "subscription.active":
        await handle_subscription_active(event["data"])
    # ... handle other events
    
    return {"received": True}

Go

Go

go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var webhookSecret = os.Getenv("DODO_PAYMENTS_WEBHOOK_SECRET")

func verifySignature(payload []byte, signature, timestamp string) bool {
    signedPayload := timestamp + "." + string(payload)
    
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write([]byte(signedPayload))
    expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    
    parts := strings.Split(signature, ",")
    if len(parts) < 2 {
        return false
    }
    
    return hmac.Equal([]byte(expectedSig), []byte(parts[1]))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("webhook-signature")
    timestamp := r.Header.Get("webhook-timestamp")
    
    if !verifySignature(body, signature, timestamp) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
    
    // Check timestamp
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
        http.Error(w, "Timestamp too old", http.StatusUnauthorized)
        return
    }
    
    var event map[string]interface{}
    json.Unmarshal(body, &event)
    
    switch event["type"] {
    case "payment.succeeded":
        handlePaymentSucceeded(event["data"].(map[string]interface{}))
    case "subscription.active":
        handleSubscriptionActive(event["data"].(map[string]interface{}))
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var webhookSecret = os.Getenv("DODO_PAYMENTS_WEBHOOK_SECRET")

func verifySignature(payload []byte, signature, timestamp string) bool {
    signedPayload := timestamp + "." + string(payload)
    
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write([]byte(signedPayload))
    expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    
    parts := strings.Split(signature, ",")
    if len(parts) < 2 {
        return false
    }
    
    return hmac.Equal([]byte(expectedSig), []byte(parts[1]))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("webhook-signature")
    timestamp := r.Header.Get("webhook-timestamp")
    
    if !verifySignature(body, signature, timestamp) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
    
    // Check timestamp
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
        http.Error(w, "Timestamp too old", http.StatusUnauthorized)
        return
    }
    
    var event map[string]interface{}
    json.Unmarshal(body, &event)
    
    switch event["type"] {
    case "payment.succeeded":
        handlePaymentSucceeded(event["data"].(map[string]interface{}))
    case "subscription.active":
        handleSubscriptionActive(event["data"].(map[string]interface{}))
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

Best Practices

最佳实践

1. Always Verify Signatures

1. 始终验证签名

Never process webhooks without signature verification to prevent spoofing.
切勿在未验证签名的情况下处理Webhook,以防止伪造请求。

2. Implement Idempotency

2. 实现幂等性

Use
webhook-id
header to prevent duplicate processing:
typescript
const processedIds = new Set<string>();

if (processedIds.has(webhookId)) {
  return NextResponse.json({ received: true }); // Already processed
}
processedIds.add(webhookId);
使用
webhook-id
标头防止重复处理:
typescript
const processedIds = new Set<string>();

if (processedIds.has(webhookId)) {
  return NextResponse.json({ received: true }); // 已处理
}
processedIds.add(webhookId);

3. Respond Quickly

3. 快速响应

Return 200 immediately, process asynchronously if needed:
typescript
// Queue for async processing
await queue.add('process-webhook', event);
return NextResponse.json({ received: true });
立即返回200状态码,如有需要异步处理:
typescript
// 加入队列异步处理
await queue.add('process-webhook', event);
return NextResponse.json({ received: true });

4. Handle Retries

4. 处理重试

Dodo Payments retries failed webhooks. Design handlers to be idempotent.
Dodo Payments会重试失败的Webhook。设计处理程序以支持幂等性。

5. Log Everything

5. 记录所有操作

Keep detailed logs for debugging:
typescript
console.log(`[Webhook] ${event.type} - ${webhookId}`, {
  timestamp: event.timestamp,
  data: event.data
});

保留详细日志以便调试:
typescript
console.log(`[Webhook] ${event.type} - ${webhookId}`, {
  timestamp: event.timestamp,
  data: event.data
});

Local Development

本地开发

Using ngrok

使用ngrok

bash
undefined
bash
undefined

Start ngrok tunnel

启动ngrok隧道

ngrok http 3000
ngrok http 3000

Use the ngrok URL as your webhook endpoint

使用ngrok URL作为Webhook端点

undefined
undefined

Testing Webhooks

测试Webhook

You can trigger test webhooks from the Dodo Payments dashboard:
  1. Go to Developer → Webhooks
  2. Select your webhook
  3. Click "Send Test Event"

您可以从Dodo Payments控制台触发测试Webhook:
  1. 进入开发者 → Webhook
  2. 选择您的Webhook
  3. 点击“发送测试事件”

Troubleshooting

故障排除

Signature Verification Failing

签名验证失败

  • Ensure you're using the raw request body
  • Check webhook secret is correct
  • Verify timestamp format (Unix seconds)
  • 确保您使用的是原始请求体
  • 检查Webhook密钥是否正确
  • 验证时间戳格式(Unix秒)

Not Receiving Webhooks

未收到Webhook

  • Check endpoint is publicly accessible
  • Verify webhook is enabled in dashboard
  • Check server logs for errors
  • 检查端点是否可公开访问
  • 验证控制台中Webhook是否已启用
  • 检查服务器日志是否有错误

Duplicate Events

重复事件

  • Implement idempotency using webhook-id
  • Store processed event IDs in database

  • 使用webhook-id实现幂等性
  • 在数据库中存储已处理的事件ID

Resources

资源