Webhooks#
ترسل Povito حدثاً JSON موقّعاً بطلب POST إلى كل نقطة تسجّلها تشمل events فيها نوع الحدث. قاعدتان تجعلان الـ webhooks آمنة للبناء عليها:
- تحقّق من التوقيع على البايتات الخام قبل أن تحلّل أي شيء، بمقارنة ثابتة الزمن ونافذة زمنية من خمس دقائق.
- تعامل مع الحدث كتنبيه لا كحقيقة. استدعِ
GET /v1/checkout/sessions/{id}وتصرّف بناءً عليه. Povito نفسها لا تُعدّ أي دفع مكتملاً عند استدعاء من مزوّد قبل أن تتحقّق لدى المزوّد؛ فالزم تكاملك بالمعيار نفسه.
أنواع الأحداث#
| النوع | يُطلق حين | data.object |
|---|---|---|
checkout.session.completed |
نجحت محاولة، أو اختار المتسوّق الدفع عند الاستلام | الجلسة (payment_status إمّا paid أو requires_offline_collection) |
checkout.session.expired |
انقضى expires_at، أو استدعيت /expire |
الجلسة |
checkout.session.canceled |
ألغى المتسوّق على الصفحة | الجلسة |
payment.attempt.failed |
فشلت محاولة أو رُفضت أو أُلغيت عند المزوّد أو انتهت مهلتها؛ الجلسة عادت إلى open |
الجلسة مع failed_attempt: { attempt_id, method, gateway, status, reason } |
refund.succeeded |
تمّ استرداد | الاسترداد |
refund.failed |
فشل استرداد أو رفضه المزوّد | الاسترداد |
customer.instrument.saved |
حُفظت بطاقة لإعادة الاستخدام (المرحلة الثانية) | الأداة — لا الرمز أبداً |
customer.instrument.revoked |
أُلغيت بطاقة محفوظة (المرحلة الثانية) | الأداة |
اشترك في ["*"] لكل شيء. قد تُضاف أنواع جديدة داخل v1؛ تجاهل ما لا تعرفه.
payment.attempt.failed إعلامي — الجلسة ما زالت مفتوحة ويُعرض على المتسوّق طريقة أخرى. لا تُلغِ الطلب عند وصوله.
الإرسال#
POST /webhooks/povito HTTP/1.1
Host: your-store.example
Content-Type: application/json
User-Agent: PovitoCheckout-Webhooks/1
Povito-Signature: t=1789041871,v1=5f1a9c0b7e2d4a6f8b1c3d5e7f9a0b2c4d6e8f0a1b3c5d7e9f1a3b5c7d9e1f3a
Povito-Event-Id: evt_test_4DEFGH1J3M8XQ2K7A9BC
Povito-Event-Type: checkout.session.completed
{"id":"evt_test_4DEFGH1J3M8XQ2K7A9BC","object":"event","type":"checkout.session.completed","livemode":false,"created_at":"2026-09-10T12:04:31.000Z","data":{"object":{"id":"cs_test_7KQ4M2XA9BC3DEFGH1JK","object":"checkout.session","status":"completed","payment_status":"paid",…}}}- المهلة 10 ثوانٍ. أجب بـ
2xxبسرعة ونفّذ العمل لاحقاً؛ التحويل3xxلا يُتّبع ويُعدّ فشلاً. - تكرّر
Povito-Event-IdوPovito-Event-Typeقيمتيidوtypeمن الجسم لتوجّه الحدث وتمنع تكراره قبل التحليل. - الجسم JSON مضغوط. تحقّق من البايتات كما وصلت تماماً؛ إعادة التسلسل تغيّر المسافات وترتيب المفاتيح وتكسر التوقيع.
التحقّق من التوقيع#
Povito-Signature هو t=<ثوانٍ unix>,v1=<hex> حيث
v1 = HMAC-SHA256( endpoint_secret, "<t>" + "." + <raw request body> )ارفض الإرسال إن كان |now − t| > 300 ثانية، أو إن لم يساوِ أيُّ v1 قيمة HMAC التي حسبتها. أثناء تدوير السرّ تحمل الترويسة قيمتي v1 (السرّ القديم والجديد)؛ اقبل إن تطابقت أيّ منهما. قارن بزمن ثابت.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyPovitoSignature(rawBody, header, secret, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) {
if (!header) return false
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8")
const parts = header.split(",").map((p) => p.trim().split("="))
const t = Number(parts.find((p) => p[0] === "t")?.[1])
if (!Number.isFinite(t) || Math.abs(now - t) > toleranceSeconds) return false
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"), "hex")
return parts
.filter((p) => p[0] === "v1")
.some((p) => {
const given = Buffer.from(p[1] ?? "", "hex")
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
// Express: أبقِ الجسم خاماً — express.json() سيعيد تسلسله.
app.post("/webhooks/povito", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyPovitoSignature(req.body, req.header("povito-signature"), process.env.POVITO_WEBHOOK_SECRET)) return res.status(401).end()
const event = JSON.parse(req.body.toString("utf8"))
queue.push(event) // أكّد الاستلام الآن، وعالج لاحقاً
res.status(200).end()
})<?php
function povito_verify(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if ($header === null || $header === '') {
return false;
}
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't') {
$t = $v;
} elseif ($k === 'v1' && $v !== null) {
$signatures[] = $v;
}
}
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) { // مقارنة ثابتة الزمن
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // البايتات الخام، لا $_POST
$header = $_SERVER['HTTP_POVITO_SIGNATURE'] ?? null;
if (!povito_verify($raw, $header, getenv('POVITO_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// أدرج $event['id'] في طابور المعالجة، ثم:
http_response_code(200);import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
def povito_verify(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
t = None
signatures = []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = v
elif k == "v1" and v:
signatures.append(v)
if t is None or not t.isdigit() or abs(int(time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures) # مقارنة ثابتة الزمن
@app.post("/webhooks/povito")
def povito_webhook():
raw = request.get_data() # البايتات كما وصلت
if not povito_verify(raw, request.headers.get("Povito-Signature"), os.environ["POVITO_WEBHOOK_SECRET"]):
abort(401)
event = json.loads(raw)
queue.enqueue(event["id"], event)
return "", 200import 'dart:convert';
import 'package:crypto/crypto.dart';
bool povitoVerify(List<int> rawBody, String? header, String secret, {int tolerance = 300}) {
if (header == null || header.isEmpty) return false;
String? t;
final signatures = <String>[];
for (final part in header.split(',')) {
final i = part.indexOf('=');
if (i < 0) continue;
final k = part.substring(0, i).trim();
final v = part.substring(i + 1).trim();
if (k == 't') t = v;
if (k == 'v1') signatures.add(v);
}
final ts = int.tryParse(t ?? '');
if (ts == null) return false;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if ((now - ts).abs() > tolerance) return false;
final expected = Hmac(sha256, utf8.encode(secret)).convert([...utf8.encode('$ts.'), ...rawBody]).bytes;
for (final sig in signatures) {
final given = _hexToBytes(sig);
if (given != null && _constantTimeEquals(given, expected)) return true;
}
return false;
}
bool _constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
List<int>? _hexToBytes(String hex) {
if (hex.length.isOdd) return null;
final out = <int>[];
for (var i = 0; i < hex.length; i += 2) {
final b = int.tryParse(hex.substring(i, i + 2), radix: 16);
if (b == null) return null;
out.add(b);
}
return out;
}اختبر دالّتك على ترويسة توقّعها بنفسك: t=1789041871، والجسم {"ok":true}، والسرّ whsec_test يعطي v1= قيمة hex لـ HMAC-SHA256("whsec_test", "1789041871.{\"ok\":true}") — ثم حرّك now لتتأكّد أنّ النافذة الزمنية ترفضها. حزمة @povito/checkout-node توفّر signPayload لهذا تحديداً.
معالجة الأحداث دون تكرار#
الإرسال يتكرّر، وإعادة الإرسال زرّ، وقد يصل الحدث نفسه إلى نقطتين. اجعل معالجتك مفتاحها event.id:
if (await store.has(event.id)) return ack()
const session = await povito.get(`/checkout/sessions/${event.data.object.id}`) // الحقيقة
switch (session.payment_status) {
case "paid": await fulfil(session.reference_id, session.payment); break
case "requires_offline_collection": await dispatchForCash(session.reference_id); break
}
await store.add(event.id)
ack()وعالج الأحداث المتأخّرة أيضاً: قد يصل checkout.session.expired بعد أن رأيت completed من GET — الجلسة التي تجلبها هي دائماً الحالية، فالتصرّف بناءً على الجلب لا الحمولة يُبقيك على صواب.
إعادة المحاولة والتعطيل التلقائي#
يُعدّ الإرسال فاشلاً حين تعيد نقطتك أي شيء خارج 2xx، أو تحوّل، أو تستغرق أكثر من 10 ثوانٍ. تُعاد المحاولة على جدول ثابت:
| المحاولة | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| التأخير بعد السابقة | — | 1 د | 5 د | 30 د | 2 س | 6 س | 24 س | 24 س |
ثماني محاولات على مدى نحو يومين ونصف. وإن ظلّت نقطة تفشل باستمرار لثلاثة أيام تُعطَّل — status: "disabled"، disabled_reason: "delivery_failures" — ويُراسَل جهة اتصال التاجر. يتوقّف الإرسال إلى النقطة المعطّلة؛ وتبقى الأحداث مسجّلة ويمكن إعادة إرسالها بعد إعادة التفعيل:
curl -X PATCH https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ \
-H "Authorization: Bearer $POVITO_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "enabled" }'نجاح واحد يصفّر عدّاد الفشل.
إدارة النقاط#
| الاستدعاء | الغرض |
|---|---|
POST /v1/webhook_endpoints |
{ url, events[], mode, description? } ← النقطة مع secret، يُعرض مرّة واحدة. يجب أن يطابق mode وضع مفتاحك. |
GET /v1/webhook_endpoints، GET …/{id} |
القائمة (لهذا الوضع) أو القراءة؛ لا تتضمّن السرّ أبداً. |
PATCH …/{id} |
تغيير url أو events أو description أو status: enabled | disabled. |
DELETE …/{id} |
حذف؛ 204. |
POST …/{id}/rotate_secret |
سرّ جديد يُعرض مرّة واحدة؛ يظلّ القديم يعمل 24 ساعة. |
POST …/{id}/test |
{ type } ← 202 { event_id }؛ يُدرج حدث اصطناعي بهذا النوع في طابور هذه النقطة. |
يجب أن تكون روابط النقاط بـ https://. الأهداف التي تُحلّ إلى عناوين خاصة أو محلية، أو localhost، أو *.internal، أو *.local، أو التي تحمل بيانات اعتماد في الرابط، تُرفض بـ 400 validation_error (تكون details[].issue إحدى private_target أو credentials أو https_required أو unresolvable).
الأحداث والنقاط لكل وضع: مفتاح الاختبار يسجّل نقاط اختبار ويستقبل أحداث اختبار؛ سجّل نقطة إنتاج بمفتاح الإنتاج قبل الانتقال.
تدوير السرّ#
curl -X POST https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ/rotate_secret \
-H "Authorization: Bearer $POVITO_SECRET_KEY"طوال الساعات الأربع والعشرين التالية يُوقَّع كل إرسال بـ السرّين — تحمل الترويسة قيمتي v1= — فتنشر السرّ الجديد دون فجوة. دوال التحقّق أعلاه تقبل أي v1 مطابقة أصلاً.
أداة الاختبار#
curl -X POST https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ/test \
-H "Authorization: Bearer $POVITO_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "checkout.session.completed" }'يُوقَّع الحدث الاصطناعي ويُرسل كحدث حقيقي ويظهر في GET /events. لكنّ data.object فيه علامة عيّنة لا جلسة: { "id": "cs_test_SAMPLE0000000000000000", "object": "checkout.session", "sample": true, "type": "…" }. المعالج الذي يتّبع قاعدة «اجلب ثم تصرّف» سيحصل على 404 session_not_found عند الجلب — وهي النتيجة الصحيحة للاختبار، وتأكيد جيّد أنّك لا تنفّذ الطلب من الحمولة.
سجلّ الأحداث#
يُحتفظ بكل حدث أنتجته كائناتك لثلاثين يوماً ويمكن قراءته دون webhooks — مفيد للمطابقة ولتدارك ما فات بعد انقطاع.
curl "https://api.checkout.povito.com/v1/events?type=checkout.session.completed&since=2026-09-10T00:00:00Z" \
-H "Authorization: Bearer $POVITO_SECRET_KEY"يضيف GET /v1/events/{id} المصفوفة deliveries[]: صفّ لكل محاولة لكل نقطة مع endpoint_id وattempt_no وstatus_code وerror (http_503، timeout، …) وdelivered_at وnext_retry_at.
إعادة الإرسال#
curl -X POST https://api.checkout.povito.com/v1/events/evt_test_4DEFGH1J3M8XQ2K7A9BC/redeliver \
-H "Authorization: Bearer $POVITO_SECRET_KEY"202 { "endpoints": 1 } — يُدرج الحدث مجدداً في طابور كل نقطة مفعّلة تطابق events فيها، كمحاولة برقم جديد. الجسم وطابع التوقيع الزمني جديدان؛ أما event.id فهو نفسه، ولهذا يعتمد معالجك عليه.
قائمة التحقّق#
- نقطة HTTPS على نطاق عامّ تجيب بـ
2xxخلال 10 ثوانٍ. - تحقّق من التوقيع على الجسم الخام، بمقارنة ثابتة الزمن، ونافذة 300 ثانية، وقبول أي
v1مطابقة. - منع التكرار عبر
event.id؛ وجلب الجلسة قبل التصرّف. - نقطة لكل وضع؛ أبقِ السرّ خارج المستودع؛ ودوّره إن تسرّب.