mcpField
W
webhook-integration

webhook-integration

github.com/dodopayments开发131.1k 次安装3 周前更新
代码仓库

关于 webhook-integration

面向接入 Dodo Payments 的开发者,提供支付、订阅和退款 Webhook 的实现指导,涵盖验签、幂等处理与本地测试。

主要功能

  1. 01验证 Webhook 签名
  2. 02保留原始请求体
  3. 03按事件类型处理
  4. 04使用 webhook-id 去重
  5. 05本地测试 Webhook
  6. 06处理重试与乱序事件

适用场景

  1. 01搭建支付事件接收端点
  2. 02同步订阅与退款状态
  3. 03安全处理重复投递
  4. 04开发期间测试事件处理

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dodopayments/skills --skill webhook-integration

安装后在 Claude Code 中输入/webhook-integration确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
common-mistakes.md
event-catalog.md
local-testing.md

详细介绍

Dodo Payments Webhook 集成

Webhook 会在付款事件发生时实时发送通知。你可以用它自动化工作流、更新数据库、发送确认信息,并确保系统保持同步。

何时使用此技能

  • 设置 Webhook 端点,以接收付款、订阅或退款事件
  • 实现签名验证,确保 Webhook 的真实性
  • 处理特定事件类型(付款成功、订阅续订等)
  • 在开发期间于本地测试 Webhook
  • 确保 Webhook 处理具有幂等性,以应对重试

核心概念

Webhook:事件发生时,Dodo 向你的端点发送的 HTTP POST 请求。

签名验证:用于证明 Webhook 来自 Dodo 而非攻击者的加密凭证。生产环境中必须进行验证。

幂等性:多次处理同一个 Webhook 会产生相同结果。使用 webhook-id 标头检测并跳过重复项。

原始请求体:收到的来自 Dodo 的确切字节内容,尚未经过解析。签名验证必需此内容。


设置:创建 Webhook 端点

1. 在控制面板中创建端点
  1. 前往 Developer → Webhooks
  2. 点击 Create Webhook
  3. 输入你的端点 URL(生产环境中必须使用 HTTPS)
  4. 选择你要接收的事件
  5. 复制签名密钥
2. 保存签名密钥
export DODO_PAYMENTS_WEBHOOK_KEY=whsec_...

SDK 会自动读取此密钥。你也可以在初始化客户端时显式传入。


Webhook 标头和载荷

每个 Webhook 请求都包含三个必需的标头(均为小写,并以连字符分隔):

标头示例用途
webhook-idevt_abc123此 Webhook 投递的唯一标识符
webhook-signaturev1,base64_signature_here用于验证的 HMAC-SHA256 签名
webhook-timestamp1704067200发送事件时的 Unix 时间戳(秒)

请求体为 JSON:

{
  "business_id": "bus_xxxxx",
  "type": "payment.succeeded",
  "timestamp": "2024-01-01T12:00:00Z",
  "data": {
    "payload_type": "Payment",
    "payment_id": "pay_xxxxx",
    "status": "succeeded",
    "total_amount": 2999,
    "currency": "USD",
    "customer": {
      "customer_id": "cus_xxxxx",
      "email": "[email protected]",
      "name": "John Doe"
    }
  }
}

验证:核心环节

处理前务必验证签名。 未经验证的 Webhook 可能遭到伪造。

首选:SDK 辅助工具

这是最简单、最安全的方式。SDK 会处理所有验证细节。

TypeScript/Node:

import DodoPayments from 'dodopayments';
import express from 'express';

const app = express();
app.use(express.raw({ type: 'application/json' }));

// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';

const client = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment,
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});

app.post('/webhook', async (req, res) => {
  try {
    const unwrapped = client.webhooks.unwrap(req.body.toString(), {
      headers: {
        'webhook-id': req.headers['webhook-id'] as string,
        'webhook-signature': req.headers['webhook-signature'] as string,
        'webhook-timestamp': req.headers['webhook-timestamp'] as string,
      },
    });
    
    // Signature verified. Process the event.
    console.log(`Received ${unwrapped.type}`);
    res.json({ received: true });
  } catch (error) {
    res.status(401).json({ error: 'Invalid signature' });
  }
});

Python:

from typing import Literal
from fastapi import FastAPI, Request, HTTPException
from dodopayments import DodoPayments
import os

# `environment` is Literal["live_mode", "test_mode"], not str. Passing the raw
# variable through raises at construction: unset gives
# `ValueError: Unknown environment: None`, and a typo like "test" gives
# `Unknown environment: test`. Narrow it, defaulting to test mode so a
# misconfigured variable can never select live.
ENVIRONMENT: Literal["live_mode", "test_mode"] = (
    "live_mode" if os.getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" else "test_mode"
)

app = FastAPI()
client = DodoPayments(
    bearer_token=os.getenv("DODO_PAYMENTS_API_KEY"),
    environment=ENVIRONMENT,
    webhook_key=os.getenv("DODO_PAYMENTS_WEBHOOK_KEY"),
)

@app.post("/webhook")
async def handle_webhook(request: Request):
    try:
        unwrapped = client.webhooks.unwrap(
            await request.body(),
            headers={
                "webhook-id": request.headers.get("webhook-id", ""),
                "webhook-signature": request.headers.get("webhook-signature", ""),
                "webhook-timestamp": request.headers.get("webhook-timestamp", ""),
            },
        )
        # Signature verified. Process the event.
        return {"received": True}
    except Exception:
        raise HTTPException(status_code=401, detail="Invalid signature")

Go:

import (
	"io"
	"net/http"
	"os"

	"github.com/dodopayments/dodopayments-go"
	"github.com/dodopayments/dodopayments-go/option"
)

// The Go SDK has no WithEnvironment(string). It exposes two explicit options,
// so narrow here and default to test mode: an unset or misspelled variable must
// never select live mode.
func dodoEnvironment() option.RequestOption {
	if os.Getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" {
		return option.WithEnvironmentLiveMode()
	}
	return option.WithEnvironmentTestMode()
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	client := dodopayments.NewClient(
		option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
		dodoEnvironment(),
		option.WithWebhookKey(os.Getenv("DODO_PAYMENTS_WEBHOOK_KEY")),
	)

	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusBadRequest)
		return
	}

	// Unwrap takes the raw body and the request headers directly. It is not
	// context-aware and does not take a map: the signature is
	// Unwrap(payload []byte, headers http.Header, opts ...option.RequestOption).
	if _, err := client.Webhooks.Unwrap(rawBody, r.Header); err != nil {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	// Signature verified. Process the event.
	w.WriteHeader(http.StatusOK)
}
替代方案:standardwebhooks 包

如果你更倾向于手动验证,或未使用 Dodo SDK:

import { Webhook } from "standardwebhooks";
import express from "express";

const app = express();
app.use(express.raw({ type: "application/json" }));

const webhook = new Webhook(process.env.DODO_PAYMENTS_WEBHOOK_KEY);

app.post("/webhooks/dodo", async (req, res) => {
  try {
    const payload = req.body.toString();
    await webhook.verify(payload, req.headers);
    
    const event = JSON.parse(payload);
    console.log(`Received ${event.type}`);
    res.json({ received: true });
  } catch (error) {
    res.status(401).json({ error: "Invalid signature" });
  }
});
unwrap() 与 unsafeUnwrap()
  • unwrap() 会验证签名。所有生产环境的 Webhook 都应使用此方法。
  • unsafeUnwrap() 会跳过验证。仅用于来自 dodo wh trigger 的未签名测试载荷。

原始请求体要求

签名验证需要 Dodo 发送的确切字节内容。如果先解析 JSON,再重新序列化,字节内容就会改变,验证也会失败。

特定框架的设置:

框架原始请求体设置
Expressapp.use(express.raw({ type: 'application/json' }))
Next.js在路由处理程序中使用 req.text();无需中间件
Fastify自定义内容类型解析器(参见适配器文档)
Hono内置支持;无需特殊设置
FastAPIawait request.body() 返回字节
Goio.ReadAll(r.Body)

Webhook 事件目录

完整指南:references/event-catalog.md。

涵盖:

  • 付款事件
  • 订阅事件
  • 退款事件
  • 争议事件
  • 许可证密钥事件
  • 权益授予事件
  • 额度事件
  • 恢复与催款事件
  • 付款事件

生产级处理程序模式

先快速持久化记录事件,再异步处理,并使用幂等键。在工作器中,在一个数据库事务内插入幂等声明并应用所有持久化业务变更。如果处理时抛出异常,事务会回滚该声明,以便安全重试任务:

import DodoPayments from 'dodopayments';
import express from 'express';
import { Queue, Worker } from 'bullmq';

// BullMQ is illustrative; use your preferred durable async queue.

const app = express();
app.use(express.raw({ type: 'application/json' }));

// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';

const client = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment,
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});

type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
type WebhookJob = { event: WebhookEvent; webhookId: string };

const connection = {
  host: process.env.REDIS_HOST ?? '127.0.0.1',
  port: Number(process.env.REDIS_PORT ?? '6379'),
};
const eventQueue = new Queue<WebhookJob>('webhook-events', { connection });

app.post('/webhook', async (req, res) => {
  let unwrapped: WebhookEvent;
  
  try {
    unwrapped = client.webhooks.unwrap(req.body.toString(), {
      headers: {
        'webhook-id': req.headers['webhook-id'] as string,
        'webhook-signature': req.headers['webhook-signature'] as string,
        'webhook-timestamp': req.headers['webhook-timestamp'] as string,
      },
    });
  } catch (error) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // Durably enqueue before acknowledging, keyed by webhook-id for idempotency
  const webhookId = req.headers['webhook-id'] as string;
  try {
    await eventQueue.add(
      `process-${unwrapped.type}`,
      { event: unwrapped, webhookId },
      {
        jobId: webhookId, // Prevents duplicate queue entries
        attempts: 8,
        backoff: { type: 'exponential', delay: 1000 },
      }
    );
    return res.json({ received: true });
  } catch (error) {
    console.error('Failed to persist webhook', error);
    return res.status(503).json({ error: 'Webhook persistence failed' });
  }
});

// Async worker. webhookLog.webhookId must have a unique constraint.
const worker = new Worker<WebhookJob>(
  'webhook-events',
  async (job) => {
    const { event, webhookId } = job.data;

    await db.$transaction(async (tx) => {
      const claim = await tx.webhookLog.createMany({
        data: [{ webhookId, eventType: event.type }],
        skipDuplicates: true,
      });
      if (claim.count === 0) return;

      switch (event.type) {
        case 'payment.succeeded':
          await handlePaymentSucceeded(event.data, tx);
          break;
        case 'subscription.active':
          await handleSubscriptionActive(event.data, tx);
          break;
        // ... handle other events
      }
    });
  },
  { connection }
);

上述处理程序必须通过 tx 执行权益写入。通过事务性发件箱排队发送电子邮件或执行其他外部工作;数据库事务无法回滚已发送的外部请求。


交付语义

了解 Dodo 如何交付 Webhook:

属性行为
超时连接和读取均为 30 秒
成功任何 2xx 响应都会确认交付。持久化记录事件后立即返回 200。
失败任何非 2xx 响应都会触发重试。
重试共尝试八次:立即、5s、5m、30m、2h、5h、10h、10h
幂等性使用 webhook-id 检测并跳过重复事件
顺序不保证顺序。事件可能乱序到达。
负载时效性交付的内容包含交付时资源的最新状态
传输在生产环境中使用 HTTPS

框架适配器快捷方式

如果你使用受支持的框架,请使用官方适配器包来内置 Webhook 处理:

框架包Webhook 处理程序
Next.js@dodopayments/nextjsWebhooks({ webhookKey, onPayload })
Nuxt@dodopayments/nuxtWebhooks({ webhookKey, onPayload })
Express@dodopayments/expressWebhooks({ webhookKey, onPayload }) - 先注册 express.json(),不要使用 express.raw()(它会针对解析后的 req.body 进行验证)
Fastify@dodopayments/fastifyWebhooks({ webhookKey, onPayload })
Hono@dodopayments/honoWebhooks({ webhookKey, onPayload })
Astro@dodopayments/astroWebhooks({ webhookKey, onPayload })
SvelteKit@dodopayments/sveltekitWebhooks({ webhookKey, onPayload })
Remix@dodopayments/remixWebhooks({ webhookKey, onPayload })
TanStack Start@dodopayments/tanstackWebhooks({ webhookKey, onPayload })
Bun@dodopayments/bunWebhooks({ webhookKey, onPayload })
Better Auth@dodopayments/better-auth使用 webhooks({ webhookKey, onPayload }) 的插件
Convex@dodopayments/convex带有已验证 HTTP 处理程序的组件

Next.js 示例:

适配器回调不会暴露 webhook-id,因此此支付示例使用 payment_id 作为稳定键,并将声明与持久化履约操作一并提交。对于没有经过验证的稳定标识符的事件类型,请使用会暴露 webhook-id 的处理程序。

// app/api/webhook/dodo-payments/route.ts
import { Webhooks } from "@dodopayments/nextjs";

export const POST = Webhooks({
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
  onPayload: async (payload) => {
    // payload is already verified
    console.log(`Received ${payload.type}`);

    if (payload.type !== 'payment.succeeded') return;

    // webhookLog.webhookId must have a unique constraint. If fulfillment
    // throws, the claim rolls back and Dodo's redelivery can retry it.
    await db.$transaction(async (tx) => {
      const claim = await tx.webhookLog.createMany({
        data: [{
          webhookId: payload.data.payment_id,
          eventType: payload.type,
        }],
        skipDuplicates: true,
      });
      if (claim.count === 0) return;

      await handlePaymentSucceeded(payload.data, tx);
    });
  },
});

仅在会暴露 webhook-id 的处理程序中处理 subscription.active,然后在同一事务中提交该声明和权益变更。


本地测试

完整指南:references/local-testing.md。

涵盖:

  • 控制面板测试工具
  • CLI:实时转发
  • CLI:未签名的模拟事件
  • 隧道

常见错误

完整指南:references/common-mistakes.md。

涵盖:

  • 对 webhook-id.webhook-timestamp.raw_body 以外的内容进行签名
  • 重新序列化解析后的正文
  • 对签名标头进行简单的逗号分割
  • timingSafeEqual 因长度不匹配而抛出异常
  • 根据 return_url 而非已验证的 Webhook 授予访问权限
  • 在响应 2xx 前执行耗时操作
  • 在持久化存储前确认收到事件
  • 在完成履约前提交幂等声明

Resources

评论

支持 Markdown