開発者が最も尋ねるWebhookパターン。
信頼できるWebhookハンドラーは、順番に4つのことを行います:生のボディに対して署名を検証する(Nodeのwebhooks.verify、他の場所の文書化されたHMAC)、イベントIDで重複を排除する、作業をバックグラウンドキューにエンキューする、そして200を返します。長時間実行される作業はワーカーで行われ、キュー層で再試行と冪等性が確保されます。失敗イベントがないため、スケジュールされたスイープは支払いを待っているジョブのタイムアウトを行い、それらを調整します。
始める前に。
- 動作するエージェントプロファイルとダッシュボードからの署名シークレット(設定 - Webhooks)。
- 生のボディアクセスを持つWebフレームワーク -
express.rawを使用したExpress、FastAPI、Flaskなど。自動解析JSONミドルウェアは署名検証を破ります。 - ジョブキュー: BullMQ (Node) または Celery/arq (Python)。Webhookは迅速に200を返し、キューが遅い作業を行います。
- アップサートプライミティブを持つデータベース(Postgresが機能します; Redis SET NXも短命の重複排除に機能します)。
- 公開HTTPSエンドポイント - 開発中、ngrokまたはデプロイプレビュー。送信者はプライベートURLには配信しません。
署名を確認する。
The signature is HMAC-SHA256 over {t}.{rawBody} with your webhook secret, hex-encoded, in the X-Blockchain0x-Signature header (t=<unix>,v1=<hex>), inside a 5-minute replay window. In Node, webhooks.verify from @blockchain0x/node does it and returns a discriminated union; in other languages compute the same HMAC and compare in constant time. Raw-body access matters: if the bytes you sign locally differ from the bytes that arrived, it fails.
import express from "express";
import { webhooks } from "@blockchain0x/node";
const app = express();
// Raw body so the HMAC matches the exact bytes on the wire.
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/payment", (req, res) => {
const result = webhooks.verify({
headers: req.headers,
rawBody: req.body, // Buffer, raw bytes
secret: process.env.BLOCKCHAIN0X_WEBHOOK_SECRET!,
});
// Discriminated union: branch on ok, no try/catch.
if (!result.ok) return res.status(400).json({ code: result.code });
// result.eventType / result.eventId are now set.
handleEvent(result);
res.status(200).send("ok");
});import hmac, hashlib, os, time
from flask import request
SECRET = os.environ["BLOCKCHAIN0X_WEBHOOK_SECRET"].encode()
# In Node, webhooks.verify does this. In Python, verify by hand against the
# documented algorithm: HMAC-SHA256 over "{t}.{rawBody}", 300s replay window.
def verify_signature(raw_body: bytes) -> bool:
sig = request.headers.get("X-Blockchain0x-Signature", "")
ts = request.headers.get("X-Blockchain0x-Timestamp", "")
parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
t, v1 = parts.get("t", ts), parts.get("v1", sig)
want = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1) and abs(time.time() - int(t)) <= 300ハンドラーを冪等にしてください。
Webhooks retry on any non-2xx response, and the same event will arrive multiple times under load even when nothing has gone wrong. Dedupe on the event's id using a database upsert. If the row already exists, skip; if it does not, insert and proceed. Postgres makes this a single statement.
// Pseudocode for a Postgres-backed dedupe table. Replace with your DB of choice.
async function processEventOnce(eventId: string, body: object) {
// INSERT ... ON CONFLICT DO NOTHING returns rowCount === 0 on duplicate.
const inserted = await db.query(
"INSERT INTO webhook_events(id) VALUES ($1) ON CONFLICT DO NOTHING",
[eventId],
);
if (inserted.rowCount === 0) return; // Already processed.
await handleEvent(body);
}async def process_event_once(event_id: str, body: dict):
# INSERT ... ON CONFLICT DO NOTHING returns 0 rows on duplicate.
inserted = await db.execute(
"INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
event_id,
)
if inserted == "INSERT 0 0": # asyncpg-style status
return # Already processed.
await handle_event(body)キューに追加して200を迅速に返す。
Webhookエンドポイントは1秒以内に応答する必要があります。遅いとタイムアウトや再試行を招きます。パターンは:検証、キューに入れる、応答する。キューは、再試行、指数バックオフ、および独自の冪等性を持つワーカーで実際の配信を実行します。BullMQとCeleryはどちらもジョブごとのIDをサポートしており、同じイベントの再キューイングを防ぎます。
// Express handler: verify, enqueue, return 200 fast.
import { Queue } from "bullmq";
import { webhooks } from "@blockchain0x/node";
const paymentQueue = new Queue("payments");
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
const result = webhooks.verify({
headers: req.headers,
rawBody: req.body,
secret: process.env.BLOCKCHAIN0X_WEBHOOK_SECRET!,
});
if (!result.ok) return res.status(400).json({ code: result.code });
await paymentQueue.add(result.eventType, { raw: req.body.toString() }, {
jobId: result.eventId, // Idempotency key.
removeOnComplete: true,
attempts: 5,
backoff: { type: "exponential", delay: 1000 },
});
res.status(200).send("ok");
},
);
// Worker file:
import { Worker } from "bullmq";
new Worker("payments", async (job) => {
await handleEvent(job.data);
});# Flask handler enqueues to Celery (or arq) and returns 200 quickly.
from celery import Celery
from flask import request
celery = Celery("payments", broker=os.environ["REDIS_URL"])
@celery.task(bind=True, max_retries=5)
def handle_payment_event(self, event_type, raw):
try:
process_event_once(event_type, raw)
except Exception as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
@app.post("/webhooks/payment")
def webhook():
raw = request.get_data()
if not verify_signature(raw):
abort(401)
event_id = request.headers.get("X-Blockchain0x-Event-Id", "")
event_type = request.headers.get("X-Blockchain0x-Event-Type", "")
handle_payment_event.apply_async(args=[event_type, raw.decode()], task_id=event_id)
return "ok", 200arqはPython側でも同じ形を持ちます - 決定論的なジョブIDでタスクを登録し、キューがリトライを処理できるようにします。重要な制約は、エンキュー自体が速くなければならない(Redisへの単一の往復)ということです。リモート呼び出しでWebhookをブロックしないでください。
決済が行われない支払いを処理します。
失敗Webhookはありません - 購入者が放棄した場合、イベントは届かず、エージェントは「支払い待機中」にスタックしたままになります。したがって、自分で検出してください:待機しすぎたジョブに対して定期的なスイープを実行し、実際に決済された場合に備えてtransactions.getを使用してチェーンに対して調整を行い、保持されたリソースを解放し、ジョブを未払いの端末状態に移動し、(適切であれば)結果をユーザーに提示します。
async function sweepStaleAwaitingPayment() {
for (const job of await findJobsAwaitingPaymentOlderThan("1h")) {
// Reconcile against the chain before giving up.
const tx = job.txHash ? await client.transactions.get(job.txHash) : null;
if (tx) { markJobPaid(job.id); continue; } // It actually settled.
// 1. Release any held resources tied to the job.
releaseHeldResources(job.id);
// 2. Move it out of 'awaiting_payment' into a terminal 'unpaid' state.
markJobUnpaid(job.id);
// 3. (Optional) Notify the user, with a fresh payment link.
notifyUser(job.userId, { template: "agent_payment_unpaid", jobId: job.id });
}
}# Run on a schedule - there is no failure webhook to wait for.
def sweep_stale_awaiting_payment():
for job in find_jobs_awaiting_payment_older_than("1h"):
tx = client.transactions.get(job["tx_hash"]) if job.get("tx_hash") else None
if tx:
mark_job_paid(job["id"]) # It actually settled.
continue
release_held_resources(job["id"])
mark_job_unpaid(job["id"])
notify_user(job["user_id"], template="agent_payment_unpaid", job_id=job["id"])イベントを削除または重複させる5つのミス。
署名を確認する前にボディを解析する
HMACは、送信者が署名した生のバイトに対して計算されなければなりません。フレームワークがハンドラーが実行される前にJSONを自動的に解析する場合、ローカルで署名したバイトは送信者が署名したバイトと一致しません(異なる空白、キーの順序、エンコーディング)ので、すべての署名は無効に見えます。ルートを設定して生のボディを受信するようにします(Express: express.raw、Flask: request.get_data)、最初に検証し、その後解析します。
Webhookハンドラー内で実際の作業を行う
ウェブフックには積極的な再試行ポリシーがあります。ハンドラが作業を配信するのに30秒かかる場合、送信者のタイムアウトが発生し、ウェブフックが再送信されます - 同じ支払いのために2つの配信が進行中になります。常に:確認し、キューに入れ、2xxを返します。実際の作業は、必要なだけの時間をかけることができるバックグラウンドワーカーで実行されます。
ビジネスロジックを伝えるためにHTTPステータスを使用する
ハンドラーがユーザーがシステムに存在しなくなったときに4xxを返すと、送信者はそれを「無効なリクエスト」と見なし、再試行を停止します。同じ条件で5xxを返すと、送信者は永遠に再試行し、キューが満杯になります。イベントを安全に永続化した(または重複として認識した)ら、200を返してください。ビジネスの決定を表現するためにHTTPステータスではなくキューロジックを使用してください。
イベントIDの代わりにペイロードハッシュでの冪等性
同じエージェントに関する2つの異なるイベント(payment.receivedと後のpayment.sent)は異なるボディを持ち、正当な理由で別々の処理が必要です。ボディハッシュで重複排除を行っている場合は、そのうちの1つを削除できます。X-Blockchain0x-Event-Id(配信ごとにユニーク)で重複排除を行い、イベントタイプに応じてハンドラーの動作を決定します。
別の確認イベントを期待しています
出荷されたイベントはpayment.received、payment.sent、wallet.deployed、およびwebhook.testです - 別の確認イベントはありません。payment.receivedは転送がブロック内にあるときに発火し、これはほとんどの作業のためのシグナルです。高額または不可逆的なものについては、transactions.getをポーリングし、自分の確認しきい値を適用してください; 存在しないイベントを待たないでください。
Webhookが完全に安全になると。
Webhookは難しい部分です。上記の4つのパターンが整っている場合、残りの作業は主に運用的です:失敗パスをテストする環境、上流エージェントがハンドラーを洪水させないようにする支出管理、そして最終的なセキュリティレビューです。
docs.blockchain0x.comに完全なリファレンスがあります。Webhook用語集: 支払いmandate。製品サーフェス: Payment API。