المطورون
Webhooks
تُخطر Webhooks خادمك عند حدوث شيء ما، مثل دفع فاتورة، حتى لا تضطر إلى الاستعلام من API باستمرار.
إضافة نقطة نهاية
في البوابة، افتح المطورون وأضف عنوان HTTPS على خادمك الذي يجب أن يستقبل الأحداث. تحصل كل نقطة نهاية على سر توقيع خاص بها يبدأ بـ whsec_. يمكن لنقطة النهاية استقبال كل الأحداث، بما في ذلك الأحداث التي تُضاف لاحقًا، أو الأحداث التي تختارها فقط. استخدم إرسال حدث تجريبي للتحقق من أن نقطة النهاية تعمل.
الأحداث
invoice.created- تم إنشاء فاتورة.
invoice.confirming- تم رصد دفعة على السلسلة وهي بانتظار التأكيدات.
invoice.paid- الفاتورة مدفوعة ومؤكدة. نفّذ الطلبات عند هذا الحدث.
invoice.underpaid- النقص في الدفعة المؤكدة يتجاوز هامش التسامح الخاص بك.
invoice.expired- انتهى الوقت قبل رصد أي دفعة.
invoice.cancelled- تم إلغاء الفاتورة.
invoice.refunded- أُعيدت العملات إلى العميل: استرداد لفاتورة مدفوعة دفعًا ناقصًا، أو تمت المطالبة برابط استرداد أنشأته. يُرسل مع كل مطالبة، لذا يُرسل الاسترداد الجزئي واحدًا أيضًا.
deposit.pending- تم رصد إيداع على السلسلة في المحفظة الثابتة لأحد عملائك، وهو بانتظار التأكيدات.
deposit.confirmed- تم تأكيد إيداع في محفظة ثابتة وأُضيف إلى رصيدك بعد خصم الرسوم: أضف المبلغ إلى رصيد ذلك العميل الآن.
deposit.dropped- إيداع في محفظة ثابتة تم رصده لكنه لم يُؤكَّد أبدًا (فشل أو أُزيل من السلسلة).
payout.sent- تم إرسال عملية صرف إلى محفظتك، أو أُرسلت حصة شريك إلى محفظة الشريك.
payout.failed- تعذّر إرسال عملية صرف.
swap.completed- اكتملت عملية مبادلة إلى عملة الاحتفاظ الخاصة بك.
الحمولة
يُرسل كل حدث كطلب HTTP POST مع نص JSON:
{
"id": "evt_…",
"type": "invoice.paid",
"created": "2026-10-28T09:41:07.000Z",
"data": {
"id": "inv_…",
"object": "invoice",
"status": "paid",
…
}
}id فريد لكل حدث، وtype هو أحد الأحداث المذكورة أعلاه، وcreated هو وقت حدوثه. في أحداث الفواتير، يكون data هو كائن الفاتورة. وفي أحداث الصرف، يكون كائن الصرف. وفي أحداث المبادلة، يصف data عملية المبادلة.
الترويسات
Content-Type- دائمًا
application/json. CM-Signature- الطابع الزمني والتوقيع:
t=وقت Unix بالثواني، ثمv1=توقيع HMAC-SHA256 بالنظام الست عشري. CM-Event-Id- معرّف الحدث، وهو نفسه
idفي النص. User-Agent- دائمًا
CryptoMerchant-Webhooks/1.
POST /webhooks/crypto-merchant HTTP/1.1
Content-Type: application/json
User-Agent: CryptoMerchant-Webhooks/1
CM-Signature: t=1792834867,v1=5f2c…
CM-Event-Id: evt_…التحقق من التوقيع
تحقق دائمًا من التوقيع قبل الوثوق بأي حدث. يمكن لأي شخص إرسال طلب إلى نقطة النهاية الخاصة بك، لكن Crypto Merchant وحدها تعرف سرّك.
- 1
اقرأ نص الطلب الخام تمامًا كما وصل، قبل تحليل JSON.
- 2
قسّم ترويسة
CM-Signatureعند الفواصل واقرأ قيمتيtوv1. - 3
احسب HMAC-SHA256 للطابع الزمني، متبوعًا بنقطة، ثم النص الخام، باستخدام سر نقطة النهاية كمفتاح، بالنظام الست عشري بأحرف صغيرة.
- 4
قارنه بـ
v1باستخدام مقارنة ثابتة الزمن. - 5
ارفض الأحداث التي يزيد عمر طابعها الزمني على خمس دقائق، حتى لا يمكن إعادة تشغيل الطلبات القديمة.
import crypto from "node:crypto";
/**
* Checks a Crypto Merchant webhook and returns the event.
* rawBody: the request body exactly as it arrived (string or Buffer).
*/
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(signatureHeader ?? "")
.split(",")
.map((part) => part.trim().split("=")),
);
const t = parts.t;
const v1 = parts.v1 ?? "";
if (!t || !v1) throw new Error("Missing signature");
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
const expected = crypto
.createHmac("sha256", secret) // your endpoint secret, whsec_…
.update(`${t}.${body}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Bad signature");
}
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
throw new Error("Timestamp too old");
}
return JSON.parse(body);
}import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
import { db } from "./db.js"; // your database, e.g. a pg Pool
// CREATE TABLE webhook_events (
// id text PRIMARY KEY, type text NOT NULL, payload jsonb NOT NULL,
// received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz
// );
const app = express();
// Keep the raw body: re-serialised JSON would not match the signature.
app.post(
"/webhooks/crypto-merchant",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
try {
// 1. Save the event before replying. A repeat delivery has the same id and is ignored.
await db.query(
"INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
[event.id, event.type, event],
);
} catch {
return res.sendStatus(500); // not saved: we send it again later
}
// 2. It's stored, so confirm it.
res.sendStatus(200);
// 3. Do the work from what you saved, here or in a background job.
// If your server stops now, the event is still waiting in the table.
processEvents().catch(console.error);
},
);
async function processEvents() {
const { rows } = await db.query(
"SELECT payload FROM webhook_events WHERE processed_at IS NULL ORDER BY received_at",
);
for (const { payload: event } of rows) {
if (event.type === "invoice.paid") {
// Mark the order in event.data.order_id as paid. Doing it twice must change nothing.
}
await db.query("UPDATE webhook_events SET processed_at = now() WHERE id = $1", [event.id]);
}
}import hashlib, hmac, json, time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> dict:
"""Checks a Crypto Merchant webhook and returns the event. raw_body: the request body exactly as it arrived."""
parts = dict(part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
raise ValueError("Missing signature")
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # your endpoint secret, whsec_…
if not hmac.compare_digest(expected, v1):
raise ValueError("Bad signature")
if abs(time.time() - int(t)) > tolerance_sec:
raise ValueError("Timestamp too old")
return json.loads(raw_body)<?php
// Checks a Crypto Merchant webhook and returns the event.
// Read the raw body with file_get_contents('php://input') and the header with $_SERVER['HTTP_CM_SIGNATURE'].
function verify_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSec = 300): array {
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($t === '' || $v1 === '') {
throw new RuntimeException('Missing signature');
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // your endpoint secret, whsec_…
if (!hash_equals($expected, $v1)) {
throw new RuntimeException('Bad signature');
}
if (abs(time() - (int) $t) > $toleranceSec) {
throw new RuntimeException('Timestamp too old');
}
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}الاستجابة وإعادة المحاولة
أجب بأي حالة 2xx خلال 10 ثوانٍ لتأكيد استلامك للحدث. أي رد آخر، أو عدم الرد، يُعدّ فشلًا، ونعيد المحاولة بعد:
- دقيقة واحدة
- 5 دقائق
- 15 دقيقة
- ساعة واحدة
- 3 ساعات
- 6 ساعات
- 12 ساعة
- 24 ساعة
أي ما يصل إلى ثماني محاولات خلال يومين تقريبًا. كل محاولة تُوقّع من جديد بطابع زمني جديد.
ممارسات جيدة
- احفظ كل حدث قبل أن تجيب، ثم نفّذ العمل انطلاقًا مما حفظته، على سبيل المثال في مهمة تعمل في الخلفية. إذا فشل الحفظ، فأجب بحالة خطأ وسنعيد إرسال الحدث.
- قد تصل الأحداث أكثر من مرة، وليس دائمًا بالترتيب. استخدم
idالخاص بالحدث لتخطي الأحداث التي عالجتها بالفعل. - قبل شحن طلب ذي قيمة عالية، يمكنك جلب الفاتورة من API للتحقق مجددًا من حالتها.
- احتفظ بسر
whsec_على خادمك، مثل مفتاح API.