关于 webhook-setup
webhook-setup 是帮助 AI Agent 构建 Webhook 接收端并配置事件发送方的开发技能,面向实现事件驱动集成的开发者。它涵盖签名验证、幂等处理、事件路由和失败处理。
主要功能
- 01设计 HTTP Webhook 接收端点
- 02验证签名并拒绝无效请求
- 03按事件类型分发处理
- 04通过事件 ID 实现幂等
- 05使用异步队列和死信队列
适用场景
- 01为支付事件构建 Webhook 接收端
- 02处理代码托管平台的事件通知
- 03为重试投递实现去重与后台处理
- 04在本地测试提供方 Webhook
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ seb1n/ awesome-ai-agent-skills --skill webhook-setup安装后在 Claude Code 中输入/webhook-setup确认已加载。
详细介绍
Webhook 设置
此技能可帮助 AI Agent 构建生产级 Webhook 接收器并配置 Webhook 发送方。Agent 会实现 HTTP 端点,用于接收事件负载、验证加密签名以认证发送方、以幂等方式处理事件以安全应对重试,并按类型将事件路由到相应的处理程序。最终可实现可靠的事件驱动集成,处理重放攻击、乱序投递和服务提供商超时等现实故障情况。
工作流
-
设计 Webhook 端点: 在稳定且难以猜测的 URL 路径上创建 HTTP POST 端点(例如
/webhooks/stripe、/webhooks/github)。端点必须快速返回200 OK响应(对大多数服务提供商而言,应在 5 秒内),以确认收到请求——耗时较长的处理应通过作业队列异步完成。只使用 HTTPS;大多数服务提供商会拒绝普通 HTTP 端点。 -
实现签名验证: 每个 Webhook 服务提供商都会使用 HMAC-SHA256、RSA 或类似方案对负载签名。在处理任何事件之前,使用服务提供商的签名密钥验证签名。使用恒定时间比较函数比较签名,以防止计时攻击。对于缺少签名或签名无效的请求,立即以
401 Unauthorized响应拒绝。读取原始请求正文进行验证——解析后的 JSON 可能与签名所依据的字节不同。 -
解析事件并按类型路由: 解析已验证的负载并提取事件类型(例如
payment_intent.succeeded、push)。使用注册表或 switch 语句将每种事件类型路由到专用的处理函数。以警告级别记录无法识别的事件类型,并返回200 OK,以防服务提供商无限期重试未处理的事件。 -
以幂等方式处理事件: 如果服务提供商未及时收到
200响应,就会重试投递 Webhook,这意味着处理程序可能会多次收到同一事件。在数据库表中存储已处理的事件 ID,并在处理前检查重复项。使用数据库事务,以原子方式将事件标记为已处理并执行其副作用。 -
添加异步处理和死信队列: 对于需要大量处理的事件(例如发送电子邮件、更新多条记录),应立即确认收到 Webhook,并将事件加入队列以供后台处理。耗尽重试次数的失败事件应移至死信队列(DLQ),以便人工检查。设置对 DLQ 队列深度的监控和告警。
-
在服务提供商一侧配置 Webhook: 向 Webhook 服务提供商注册端点 URL,选择所需的事件类型(仅订阅最少的一组),并记录签名密钥。使用服务提供商的测试/ping 功能测试 Webhook。在服务提供商控制面板中设置投递失败监控。
支持的技术
- Web 框架: Express.js、Fastify、Flask、FastAPI、Django、Rails、Spring Boot
- 队列系统: Bull/BullMQ(Redis)、Celery(Python)、Sidekiq(Ruby)、SQS、RabbitMQ
- 服务提供商: Stripe、GitHub、Slack、Twilio、SendGrid、Shopify、PayPal、Paddle
- 监控: Svix(Webhook 基础设施)、Hookdeck、ngrok(本地开发)
- 数据库: PostgreSQL、MySQL、Redis(用于幂等性跟踪)
用法
向 Agent 提供 Webhook 服务提供商(Stripe、GitHub 等)、要处理的事件以及服务器框架。Agent 将生成完整的 Webhook 接收器,包含签名验证、事件路由、幂等处理和错误处理。对于本地开发,Agent 可以设置 ngrok 或类似的隧道进行测试。
示例
示例 1:Stripe Webhook 接收器(Express.js)
const express = require("express");
const crypto = require("crypto");
const { Queue } = require("bullmq");
const app = express();
const eventQueue = new Queue("webhook-events", { connection: { host: "localhost" } });
// CRITICAL: Use raw body for signature verification — must be before json parser
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["stripe-signature"];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Step 1: Verify signature
let event;
try {
event = verifyStripeSignature(req.body, signature, webhookSecret);
} catch (err) {
console.error(`Signature verification failed: ${err.message}`);
return res.status(401).json({ error: "Invalid signature" });
}
// Step 2: Idempotency check — skip if already processed
const alreadyProcessed = await checkIdempotency(event.id);
if (alreadyProcessed) {
console.log(`Event ${event.id} already processed, skipping`);
return res.status(200).json({ received: true, deduplicated: true });
}
// Step 3: Acknowledge immediately, process async
try {
await eventQueue.add(event.type, {
eventId: event.id,
type: event.type,
data: event.data.object,
created: event.created,
});
await markEventReceived(event.id);
res.status(200).json({ received: true });
} catch (err) {
console.error(`Failed to enqueue event ${event.id}:`, err);
res.status(500).json({ error: "Processing failed" });
}
});
// Stripe signature verification (manual implementation)
function verifyStripeSignature(payload, signatureHeader, secret) {
const elements = signatureHeader.split(",").reduce((acc, part) => {
const [key, value] = part.split("=");
acc[key.trim()] = value;
return acc;
}, {});
const timestamp = elements["t"];
const expectedSig = elements["v1"];
// Protect against replay attacks: reject events older than 5 minutes
const tolerance = 300; // 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > tolerance) {
throw new Error("Webhook timestamp too old — possible replay attack");
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(computedSig))) {
throw new Error("Signature mismatch");
}
return JSON.parse(payload);
}
// Idempotency tracking with PostgreSQL
const { Pool } = require("pg");
const db = new Pool();
async function checkIdempotency(eventId) {
const result = await db.query(
"SELECT 1 FROM processed_webhook_events WHERE event_id = $1",
[eventId]
);
return result.rows.length > 0;
}
async function markEventReceived(eventId) {
await db.query(
"INSERT INTO processed_webhook_events (event_id, received_at) VALUES ($1, NOW()) ON CONFLICT (event_id) DO NOTHING",
[eventId]
);
}
// Event handler workers (BullMQ)
const { Worker } = require("bullmq");
const worker = new Worker("webhook-events", async (job) => {
const { eventId, type, data } = job.data;
const handlers = {
"payment_intent.succeeded": handlePaymentSucceeded,
"payment_intent.payment_failed": handlePaymentFailed,
"customer.subscription.deleted": handleSubscriptionCanceled,
"invoice.payment_failed": handleInvoicePaymentFailed,
};
const handler = handlers[type];
if (!handler) {
console.log(`Unhandled event type: ${type}`);
return;
}
await handler(data, eventId);
await db.query("UPDATE processed_webhook_events SET processed_at = NOW() WHERE event_id = $1", [eventId]);
}, { connection: { host: "localhost" } });
async function handlePaymentSucceeded(paymentIntent, eventId) {
const orderId = paymentIntent.metadata.order_id;
await db.query("UPDATE orders SET status = 'paid', payment_id = $1 WHERE id = $2", [
paymentIntent.id, orderId,
]);
console.log(`Order ${orderId} marked as paid (event: ${eventId})`);
}
async function handlePaymentFailed(paymentIntent, eventId) {
const orderId = paymentIntent.metadata.order_id;
await db.query("UPDATE orders SET status = 'payment_failed' WHERE id = $1", [orderId]);
// Trigger customer notification
console.log(`Payment failed for order ${orderId} (event: ${eventId})`);
}
async function handleSubscriptionCanceled(subscription, eventId) {
await db.query("UPDATE users SET subscription_status = 'canceled' WHERE stripe_customer_id = $1", [
subscription.customer,
]);
}
async function handleInvoicePaymentFailed(invoice, eventId) {
console.log(`Invoice ${invoice.id} payment failed, attempt ${invoice.attempt_count}`);
}
app.listen(3000, () => console.log("Webhook receiver listening on port 3000"));
示例 2:用于 CI 触发的 GitHub Webhook(Python/FastAPI)
import hashlib
import hmac
import os
import logging
from datetime import datetime, timezone
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from pydantic import BaseModel
app = FastAPI()
logger = logging.getLogger(__name__)
GITHUB_WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
# In-memory idempotency store (use Redis or PostgreSQL in production)
processed_deliveries: set[str] = set()
def verify_github_signature(payload_body: bytes, signature_header: str | None) -> None:
"""Verify the GitHub webhook signature using HMAC-SHA256."""
if not signature_header:
raise HTTPException(status_code=401, detail="Missing X-Hub-Signature-256 header")
expected_signature = "sha256=" + hmac.new(
GITHUB_WEBHOOK_SECRET.encode(),
payload_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, signature_header):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
@app.post("/webhooks/github")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
# Step 1: Read raw body and verify signature
body = await request.body()
signature = request.headers.get("X-Hub-Signature-256")
verify_github_signature(body, signature)
# Step 2: Idempotency check using delivery ID
delivery_id = request.headers.get("X-GitHub-Delivery")
if delivery_id in processed_deliveries:
logger.info(f"Duplicate delivery {delivery_id}, skipping")
return {"status": "duplicate", "delivery_id": delivery_id}
# Step 3: Parse event and route
event_type = request.headers.get("X-GitHub-Event")
payload = await request.json()
logger.info(f"Received GitHub event: {event_type} (delivery: {delivery_id})")
# Acknowledge immediately, process in background
processed_deliveries.add(delivery_id)
background_tasks.add_task(process_github_event, event_type, payload, delivery_id)
return {"status": "accepted", "event": event_type, "delivery_id": delivery_id}
async def process_github_event(event_type: str, payload: dict, delivery_id: str):
"""Route and process GitHub webhook events."""
handlers = {
"push": handle_push,
"pull_request": handle_pull_request,
"issues": handle_issue,
"ping": handle_ping,
}
handler = handlers.get(event_type)
if not handler:
logger.warning(f"Unhandled GitHub event type: {event_type}")
return
try:
await handler(payload)
logger.info(f"Successfully processed {event_type} (delivery: {delivery_id})")
except Exception as e:
logger.error(f"Failed to process {event_type} (delivery: {delivery_id}): {e}")
# In production, move to dead letter queue for retry
raise
async def handle_push(payload: dict):
"""Trigger CI pipeline on push to main branch."""
ref = payload.get("ref", "")
repo = payload["repository"]["full_name"]
commits = payload.get("commits", [])
if ref != "refs/heads/main":
logger.info(f"Ignoring push to {ref} on {repo}")
return
commit_messages = [c["message"].split("\n")[0] for c in commits]
logger.info(f"Push to main on {repo}: {len(commits)} commits")
# Check if any commits modify CI-relevant files
changed_files = set()
for commit in commits:
changed_files.update(commit.get("added", []))
changed_files.update(commit.get("modified", []))
# Trigger appropriate CI jobs
if any(f.startswith("src/") for f in changed_files):
await trigger_ci_pipeline(repo, "build-and-test", payload["after"])
if any(f.startswith("infrastructure/") for f in changed_files):
await trigger_ci_pipeline(repo, "infrastructure-plan", payload["after"])
logger.info(f"CI pipelines triggered for {repo}@{payload['after'][:8]}")
async def handle_pull_request(payload: dict):
"""Run checks on pull request events."""
action = payload["action"]
pr = payload["pull_request"]
repo = payload["repository"]["full_name"]
if action in ("opened", "synchronize"):
logger.info(f"PR #{pr['number']} {action} on {repo}: {pr['title']}")
await trigger_ci_pipeline(repo, "pr-checks", pr["head"]["sha"])
elif action == "closed" and pr.get("merged"):
logger.info(f"PR #{pr['number']} merged on {repo}")
await trigger_ci_pipeline(repo, "deploy-staging", pr["merge_commit_sha"])
async def handle_issue(payload: dict):
"""Log issue events."""
action = payload["action"]
issue = payload["issue"]
logger.info(f"Issue #{issue['number']} {action}: {issue['title']}")
async def handle_ping(payload: dict):
"""Respond to GitHub's test ping."""
logger.info(f"Ping received. Hook ID: {payload.get('hook_id')}, Zen: {payload.get('zen')}")
async def trigger_ci_pipeline(repo: str, pipeline: str, commit_sha: str):
"""Trigger a CI pipeline (stub — replace with actual CI API call)."""
logger.info(f"Triggering {pipeline} for {repo}@{commit_sha[:8]}")
# In production: POST to Jenkins, GitHub Actions, CircleCI, etc.
最佳实践
- 快速响应,稍后处理。 在 3-5 秒内返回
200 OK。大多数提供商会在 10-30 秒后超时并重试,导致重复处理。将事件加入后台作业队列,供后续实际处理。 - 处理任何 Webhook 负载前,务必验证签名。 使用提供商的签名密钥和恒定时间比较函数。即使在开发环境中,也绝不要跳过验证。
- 使用事件 ID 实现幂等性。 将提供商的投递 ID/事件 ID 存入数据库,并在处理前检查是否重复。使用
INSERT ... ON CONFLICT DO NOTHING实现原子去重。 - 使用死信队列存放所有重试后仍处理失败的事件。监控 DLQ 深度并设置警报。提供工具,以便修复 Bug 后重放 DLQ 中的事件。
- 只订阅需要处理的事件。 接收不处理的事件会浪费带宽,并使日志产生噪声。大多数提供商允许你在配置 Webhook 时选择特定事件类型。
- 通过检查事件时间戳防范重放攻击。 拒绝超过 5 分钟的事件(可根据你的容忍度配置)。Stripe、GitHub 和大多数提供商都会在签名标头中包含时间戳,以便进行此项检查。
边缘情况
- 乱序投递: Webhook 可能会乱序到达(例如,
invoice.paid早于invoice.created)。应使用事件时间戳和资源的当前状态(通过 API 获取)来做出判断,而不是假定事件按顺序投递。 - 部署期间提供商重试: 如果你的服务在部署期间宕机,提供商会重试。确保幂等逻辑能够处理服务恢复后收到的同一事件。使用滚动部署,确保始终至少有一个实例可用。
- 负载大小限制: 某些提供商会发送较大的负载(尤其是包含大量差异的 GitHub 推送事件)。为端点设置适当的请求体大小限制(例如 10MB),并妥善处理
413 Payload Too Large。 - 密钥轮换: 轮换 Webhook 签名密钥时,在过渡期间同时支持新旧密钥。优先使用新密钥验证,失败时再回退到旧密钥。确认所有投递都已使用新密钥后,再移除旧密钥。
- 端点 URL 变更: 如果需要更改 Webhook URL,请先注册新 URL,再注销旧 URL。在提供商确认成功向新 URL 投递之前,让两个端点并行运行。
- 本地测试: 使用 ngrok、Cloudflare Tunnel 或提供商的 CLI(例如
stripe listen --forward-to localhost:3000/webhooks/stripe),将 Webhook 转发到本地开发服务。