التحقق من التوقيعات
اتأكد إن كل webhook جاي من XPay فعلًا قبل ما تثق فيه. HMAC-SHA256 على المحتوى الخام، بنافذة إعادة إرسال 5 دقايق.
أي حد معاه رابط نقطتك يقدر يعمل POST عليها. سر التوقيع هو اللي بيقولّك أنهي طلبات POST هي XPay فعلًا. كل webhook XPay بيسلّمه بيحمل ترويسة XPay-Signature. المعالِج بتاعك بيعيد حساب التوقيع باستخدام سر whsec_* بتاع النقطة، وبيرفض أي طلب القيمة فيه ما تطابقش.
مفيش مكتبة من XPay لده حاليًا. التحقق صغير لدرجة إنه ممكن يعيش في المعالِج بتاعك في حوالي 20 سطر.
الترويسة
XPay بيبعت ترويسة توقيع واحدة على كل تسليم:
XPay-Signature: t=1730000000,v1=a1b2c3d4...| الحقل | المعنى |
|---|---|
t | طابع وقت Unix (بالثواني) في اللحظة اللي XPay حسب فيها التوقيع. |
v1 | تشفير HMAC-SHA256 بترميز hex للطابع الزمني، و.، والمحتوى الخام، موقّع بسر النقطة. |
الحقلين الاتنين مطلوبين. الترويسة المشوّهة، أو الطلب اللي مفيهوش ترويسة خالص، المفروض يترفض على طول.
إزاي XPay بيوقّع
XPay بيحسب التوقيع كالآتي:
signedPayload = `${timestamp}.${rawRequestBody}`
signature = HMAC-SHA256(endpointSecret, signedPayload)حيث:
timestampهي نفس القيمة المبعوتة في حقلt.rawRequestBodyهي بايتات محتوى الـ JSON اللي XPay بعته. مش شكل موحّد. إعادة تسلسل الـ JSON المحلَّل من جهتك مش هتنتج نفس البايتات.
اعمل ترميز hex للملخّص وقارنه، في وقت ثابت، مع قيمة v1.
إزاي تتحقق
التحقق خمس خطوات صغيرة:
- اقرا المحتوى الخام للطلب كسلسلة نصية أو
Buffer. ما تحلّلهوش كـ JSON لسه. - اقرا ترويسة
XPay-Signatureوقسّمها على,لـt={...}وv1={...}. - ارفض لو
abs(now - t) > 300ثانية. دي نافذة إعادة الإرسال. - احسب
HMAC-SHA256(secret, "${t}.${rawBody}")واعمل ترميز hex للملخّص. - قارن الملخّص في وقت ثابت مع قيمة
v1.
بس بعد ما الخمسة كلهم ينجحوا تعمل JSON.parse(rawBody) وتتصرّف على الحدث.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyXPaySignature(
rawBody: string,
header: string | undefined,
secret: string,
): { valid: boolean; event?: unknown } {
if (!header) return { valid: false };
const parts = Object.fromEntries(
header.split(",").map((p) => {
const [k, ...rest] = p.split("=");
return [k, rest.join("=")];
}),
);
const timestamp = Number.parseInt(parts.t ?? "", 10);
const received = parts.v1;
if (!Number.isFinite(timestamp) || !received) return { valid: false };
// Replay protection
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) {
return { valid: false };
}
const computed = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(computed);
const b = Buffer.from(received);
if (a.length !== b.length) return { valid: false };
if (!crypto.timingSafeEqual(a, b)) return { valid: false };
return { valid: true, event: JSON.parse(rawBody) };
}import hmac, hashlib, time, json
TOLERANCE_SECONDS = 300
def verify_xpay_signature(raw_body: str, header: str, secret: str):
if not header:
return None
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return None
received = parts.get("v1", "")
if not received:
return None
# Replay protection
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return None
computed = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed, received):
return None
return json.loads(raw_body)الدالة بترجّع الحدث المحلَّل لما التحقق ينجح، وإشارة "غير صالح" غير كده. دايمًا حلّل الـ JSON جوّه المُتحقق (بعد فحص التوقيع)، مش قبله.
توصيل المحتوى الخام في إطار العمل بتاعك
أكتر سبب شائع لفشل التحقق هو إن إطار العمل حلّل المحتوى لـ JSON وأعاد تسلسله قبل ما المعالِج بتاعك يشتغل. المحتوى المعاد تسلسله بيبقى ليه مسافات وترتيب مفاتيح مختلفين عن البايتات اللي XPay وقّعها، فالـ HMAC مش هيطابق.
شوية وصفات حسب إطار العمل:
import express from "express";
const app = express();
// IMPORTANT: use express.raw on this exact route, before any global JSON parser.
app.post("/webhooks/xpay", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = (req.body as Buffer).toString("utf8");
const result = verifyXPaySignature(
rawBody,
req.header("XPay-Signature"),
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) return res.status(400).send("invalid signature");
handleEvent(result.event);
res.status(200).send();
});import Fastify from "fastify";
const fastify = Fastify();
// Register a content-type parser that hands you the raw bytes.
fastify.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) =>
done(null, body),
);
fastify.post("/webhooks/xpay", async (req, reply) => {
const rawBody = req.body as string;
const result = verifyXPaySignature(
rawBody,
req.headers["xpay-signature"] as string,
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) return reply.code(400).send("invalid signature");
handleEvent(result.event);
return reply.code(200).send();
});// app/api/webhooks/xpay/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // read once, before any .json()
const result = verifyXPaySignature(
rawBody,
req.headers.get("XPay-Signature") ?? undefined,
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) {
return new NextResponse("invalid signature", { status: 400 });
}
handleEvent(result.event);
return new NextResponse(null, { status: 200 });
}تعمل إيه لما التحقق يفشل
عدم تطابق التوقيع مش دايمًا هجوم. نفس مسار الكود بيمسك كتير من حالات الإعداد الغلط العادية.
| السبب | العَرَض |
|---|---|
| إطار العمل حلّل المحتوى وأعاد تسلسله | التحقق بيفشل على كل طلب. أكتر سبب شائع. |
| سر نقطة غلط | التحقق بيفشل على كل طلب. اختلاط بين سر الاختبار والحساب الفعلي، أو بتستخدم قيمة قديمة من نقطة محذوفة. |
| النقطة اتحذفت واتعملت تاني | النقطة الجديدة ليها whsec_* جديد. حدّث متغيّر البيئة بتاعك. |
| انحراف ساعة السيرفر بتاعك | رفض بسبب نافذة إعادة الإرسال. زامن ساعة السيرفر عن طريق NTP. |
| الترويسة اتعدّلت | t أو v1 ناقص. بروكسي عكسي أو ingress عدّل الترويسة. سيب XPay-Signature يعدّي من غير تعديل. |
لما التحقق يفشل، ارجع رد غير 2xx (400 هو الاختيار الصح). تدفق التسليم بتاع XPay بيعتبر ده محاولة فاشلة وبيعيد المحاولة على الجدول العادي. شوف إعادة الإرسال والمحاولات.
المعالِج اللي بيرجع 200 على توقيع غلط بيبقى معطوب بصمت: عمرك ما هتلاحظ إن التوقيع اتغير، والمهاجم اللي يلاقي رابطك يقدر يزوّر أحداث.
عدم التكرار
نفس الحدث ممكن يتسلّم أكتر من مرة. XPay بيعيد المحاولة على كل رد غير 2xx وعلى كل مهلة منتهية، وممكن كمان تشوف نسخ مكررة لو رد ناجح ما وصلش لـ XPay. لازم المعالِج بتاعك يكون بلا تكرار (idempotent). دي ناحية الـ webhook من عدم التكرار؛ علشان الـ Idempotency-Key اللي بتبعته على عمليات الكتابة، شوف عدم التكرار.
استخدم event.id على المستوى الأعلى كمفتاح إزالة التكرار:
async function handleEvent(event: { id: string; type: string; data: unknown }) {
// Skip if we've seen this id before
const inserted = await db.processedEvents.insertIfNew(event.id);
if (!inserted) return;
switch (event.type) {
case "checkout.session.completed":
await fulfillOrder(event.data);
break;
// other cases
}
}الـ event.id فريد لكل حدث وثابت عبر كل محاولات الإعادة.
أقرّ بسرعة، واشتغل في الخلفية
XPay بيعتبر التسليم ناجح بس لو المعالِج بتاعك رجّع 2xx خلال 30 ثانية. أي حاجة أبطأ بتطلق محاولة إعادة، حتى لو شغلك خلص في الآخر.
الشكل الصح هو إنك تتحقق، تزيل التكرار، تحط الحدث في طابور، وترجع 200 بأسرع ما يمكن. شغّل تنفيذ الطلبات، وإرسال الإيميلات، واستدعاءات الـ API اللاحقة، وكتابات قاعدة البيانات من عامل الطابور، مش من جوّه معالِج الطلب.
const result = verifyXPaySignature(rawBody, header, secret);
if (!result.valid) return res.status(400).send();
await queue.add("xpay-event", result.event);
return res.status(200).send();رايح فين بعد كده
إعداد نقطة نهاية
أنشئ النقطة وامسك سر whsec_* اللي هتتحقق بيه.
إعادة الإرسال والمحاولات
جدول إعادة المحاولة لما المعالِج بتاعك يرجع غير 2xx، وإزاي تعيد إرسال حدث يدويًا من لوحة التحكم.
التطوير المحلي
حوّل تسليمات الـ webhook لجهازك وأنت بتبني المعالِج.
مرجع الأحداث
كل حدث تقدر تشترك فيه، إمتى بيتطلق، والكائن اللي بيحمله.
نموذج الكائنات
إيه اللي بيحتويه data.object بتاع كل حدث، والمعرّفات اللي تحتفظ بيها في سجل الطلب بتاعك.