وبهوکها
بررسی امضا، پنجره پنجدقیقهای، حذف تکراریها، تلاشهای مجدد، و اینکه چرا محتوای وبهوک فقط یک سرنخ است
وبهوک راهی است که کوینلند رخ دادن چیزی را به شما اطلاع میدهد، بدون آنکه شما مجبور به استعلام دورهای باشید. وبهوکها درخواستهای POST امضاشده به نشانی httpsای هستند که در کنسول کسبوکار ثبت میکنید.
سه رویداد وجود دارد، و یک قاعده که از همه آنها مهمتر است: محتوای وبهوک یک سرنخ است، نه یک واقعیت. پیش از عمل کردن بر پایه آن، رکورد معتبر را بخوانید.
رویدادها
| نوع | چه زمانی | چه کاری کنید |
|---|---|---|
payment.completed | یک جلسه پرداخت شد و انتقال تسویه شد | پرداخت را بخوانید، سفارش را تحویل دهید |
session.expired | یک جلسه پرداختنشده به expires_at رسید | سبد یا رزرو را آزاد کنید |
payout.completed | یک پرداخت به مشتری یا بازپرداخت تسویه شد | پرداخت را بخوانید و پرونده مربوط به آن را ببندید |
{
"event_id": "d41f8c62-5a10-4e93-b7d8-0c2a5f6e1b34",
"type": "payment.completed",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"payment_id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"status": "completed"
}{
"event_id": "5c8b1f47-2a93-4d06-b1e8-7f0c3d9a5b62",
"type": "session.expired",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "expired"
}{
"event_id": "a17b3e50-9d24-4c81-b6f3-5e0a2c7d1948",
"type": "payout.completed",
"payout_id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed"
}رویداد payout.completed برای بازپرداخت هم فرستاده میشود — مقدار kind یا payout است یا refund،
پس بر پایه آن شاخه بگذارید و فرض نکنید. توجه کنید پرداختی که خودتان ساختهاید تا وقتی فراخوانی API شما
برگردد تسویه شده است، پس این رویداد بیشتر برای پرداختهایی اهمیت دارد که از کنسول کسبوکار انجام میشوند.
انواع رویداد جدید افزودنی هستند و میتوانند بدون تغییر نسخه ظاهر شوند، پس همیشه یک شاخه پیشفرض داشته
باشید که آنچه را نمیشناسد نادیده بگیرد. هندلری که روی type ناشناس خطا میدهد یک افزوده معمول و
سازگار با نسخه قبل را به یک قطعی در سمت شما تبدیل میکند.
بررسی امضا
هر درخواست این هدر را دارد:
x-pay-signature: t=1754999071,v1=3b8a5f9c2d1e...tمهر زمانی یونیکس بر حسب ثانیه در لحظه امضا است.v1حاصلHMAC-SHA256(webhook_secret, "{t}.{rawBody}")بهصورت hex با حروف کوچک است.
به رشتهای که امضا میشود دقت کنید: مهر زمانی، یک نقطه، و سپس بدنه خام درخواست دقیقاً همانطور که ارسال
شده. قرار دادن t در آنچه امضا میشود همان چیزی است که جلوی بازپخش یک درخواست قدیمی و واقعی با هدری
تازه را میگیرد.
import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = process.env.COINLAND_PAY_WEBHOOK_SECRET!;
// The RAW body is what was signed. A JSON parser that re-serialises the request
// produces different bytes — different key order, different whitespace — and
// every signature fails for reasons that look like a bug in ours. Take the raw
// buffer, verify, then parse.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("x-pay-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return res.sendStatus(400);
// Five-minute window, checked in BOTH directions so a clock ahead of ours
// is rejected too.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
// Acknowledge FIRST, work after: a slow handler is a retried handler.
res.sendStatus(200);
void handleEvent(JSON.parse(req.body.toString("utf8")));
},
);سه الزام، که هیچکدام قابل چشمپوشی نیست:
- بایتهای خام دقیق را هش کنید. سریالایز دوباره JSON امضا را خراب میکند. بیشتر فریمورکها را باید وادار کنید بدنه خام را به شما بدهند؛ این را پیش از هر کار دیگری انجام دهید.
- پنجره پنجدقیقهای را اعمال کنید. بدون آن، امضایی که یک بار ضبط شود برای همیشه معتبر است.
- در زمان ثابت مقایسه کنید.
crypto.timingSafeEqual،hash_equals،hmac.compare_digest.
همان کلید مخفی، توکنهای رسید را هم امضا میکند، پس یک مقدار برای محافظت و یک تابع رمزنگاری برای درست پیاده کردن وجود دارد.
بر پایه event_id تکراریها را حذف کنید
فرض کنید هر رویداد بیش از یک بار میرسد. تلاش مجدد، وقفه زمانی در سمت شما که ما نتوانستیم از یک شکست تشخیص دهیم، یک قطع شبکه — همه اینها ارسال دوباره تولید میکنند، و ارسال دوباره یک رفتار عادی است نه خطا.
event_id در همه تلاشها برای یک رویداد ثابت است. ثبتش کنید و دومی را رد کنید:
async function handle(event) {
// یک قید یگانگی روی event_id تمام سازوکار است. انجام این بررسی بهصورت یک
// SELECT و بعد یک INSERT، رقابتی به جا میگذارد که دو تلاش همزمان پیدایش
// میکنند.
const inserted = await db
.insert(webhookEvents)
.values({ eventId: event.event_id, type: event.type })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) return; // قبلاً پردازش شده
if (event.type !== "payment.completed") return;
await fulfil(event);
}ایدمپوتنسی در سمت شما همان چیزی است که سیاست تلاش مجدد را ایمن میکند. بدون آن، یک اختلال کوچک یک سفارش را دو بار ارسال میکند.
محتوای وبهوک یک سرنخ است
بدنه، شناسهها و یک وضعیت را حمل میکند. عامدانه هیچ مبلغی حمل نمیکند، چون بدنه یک وبهوک چیزی است که از شبکه به سرور شما میرسد، و مبلغی که بر پایهاش عمل میکنید باید از فراخوانیای بیاید که خودتان انجام دادهاید.
پس الگوی هندلر همیشه یکی است: بررسی کن، تکراری را حذف کن، سپس بخوان.
const API = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
const payment = await fetch(`${API}/api/pay/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
if (payment.reference_id !== order.id) return; // not this order
if (payment.currency !== order.currency) return; // not what we quoted
if (new Decimal(payment.amount).lt(order.total)) return; // underpaid
await fulfil(order, payment);آن fetch تنها خواندن معتبر در کل این جریان است. GET /payments/{id} و GET /sessions/{id} دو فراخوانیاند
که میتوانید بر پاسخشان بنا کنید.
سریع پاسخ بدهید، بعد کار کنید
بهمحض درست بودن امضا یک پاسخ ۲xx برگردانید. کار تحویل را بعد از پاسخ دادن انجام دهید، یا به یک صف بسپارید.
هندلری که کالا را ارسال میکند، ایمیل میفرستد و انبار را بهروز میکند و بعد پاسخ میدهد، هندلری است که دیر یا زود بیشتر از مهلت ارسال طول میکشد. آن وقت کار موفق میشود و ارسال بهعنوان شکست ثبت میشود، پس دوباره تلاش میشود — و بررسی ایدمپوتنسی شما تنها چیزی است که میان یک سفارش و دو سفارش ایستاده است.
تلاش مجدد و عقبنشینی
یک ارسال موفق است اگر با هر کد ۲xx پاسخ دهید. هر چیز دیگری — ۴xx، ۵xx، وقفه زمانی، خطای TLS، خطای DNS — یک تلاش ناموفق است، و کوینلند با عقبنشینی نمایی و در حدود ۲۴ ساعت دوباره تلاش میکند و بعد دست میکشد.
چون ارسالها میتوانند ساعتها فاصله داشته باشند، وبهوک را تنها مسیر خود نگیرید. دو عادت ارزان، یکپارچهسازی را در برابر وبهوکی که هرگز نمیرسد مقاوم میکند:
- صفحه
return_urlشما استعلام میکند. مشتری همانجا حاضر است و میداند پرداخت کرده، پسGET /sessions/{id}را بخوانید و همان لحظه کهcompletedگفت سفارش را پرداختشده نشان دهید. - یک بازبینی روزانه بقیه را میگیرد. روزی یک بار سفارشهای پرداختنشده اخیر را با پرداختها مغایرتگیری کنید. همین کار سفارشی را هم میگیرد که وقتی سرور شما خواب بود پرداخت شده است.
اگر تکراریها را حذف کنید، تلاش مجدد هیچ چیزی درباره درستی تغییر نمیدهد. تنها چیزی که تغییر میدهد این است که یک ارسال ازدسترفته چقدر دیر میرسد.
ثبت و چرخاندن کلید
نشانی و کلید مخفی خود را در کنسول کسبوکار تعیین کنید. الزامات:
- فقط https. نشانی وبهوک بدون رمزنگاری با
MERCHANT_WEBHOOK_URL_INVALIDرد میشود، و همینطور هر نشانی غیرقابلتحلیل یا اشارهکننده به یک آدرس خصوصی. - از اینترنت عمومی قابل دسترسی. ما نمیتوانیم به localhost یا آدرسی داخل VPN شما ارسال کنیم.
- یک نشانی. اگر لازم است رویداد را به چند سرویس پخش کنید، یک بار دریافت کنید و داخلی منتشر کنید.
چرخاندن کلید مخفی، چیزی را عوض میکند که هم وبهوکها و هم توکنهای رسید شما را امضا میکند، پس در بازه کوتاهی که تغییر منتشر میشود هر دو مقدار قدیم و جدید را بپذیرید و بعد قدیمی را حذف کنید.
فهرست بررسی
- بدنه خام، نه بدنه سریالایزشده دوباره.
- پنجره مهر زمانی اعمال شود، در هر دو جهت.
- مقایسه امضا در زمان ثابت.
- قید یگانگی روی
event_id. - پاسخ ۲xx پیش از شروع کار کند.
typeناشناس نادیده گرفته شود، نه اینکه خطا بدهد.- مبالغ از
GET /payments/{id}خوانده شود، هرگز از محتوای وبهوک.