Povitoالمطوّرون

Webhooks#

ترسل Povito حدثاً JSON موقّعاً بطلب POST إلى كل نقطة تسجّلها تشمل events فيها نوع الحدث. قاعدتان تجعلان الـ webhooks آمنة للبناء عليها:

  1. تحقّق من التوقيع على البايتات الخام قبل أن تحلّل أي شيء، بمقارنة ثابتة الزمن ونافذة زمنية من خمس دقائق.
  2. تعامل مع الحدث كتنبيه لا كحقيقة. استدعِ 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 إعلامي — الجلسة ما زالت مفتوحة ويُعرض على المتسوّق طريقة أخرى. لا تُلغِ الطلب عند وصوله.

الإرسال#

http
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> حيث

text
v1 = HMAC-SHA256( endpoint_secret, "<t>" + "." + <raw request body> )

ارفض الإرسال إن كان |now − t| > 300 ثانية، أو إن لم يساوِ أيُّ v1 قيمة HMAC التي حسبتها. أثناء تدوير السرّ تحمل الترويسة قيمتي v1 (السرّ القديم والجديد)؛ اقبل إن تطابقت أيّ منهما. قارن بزمن ثابت.

Nodejs
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()
})
PHPphp
<?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);
Python (Flask)python
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 "", 200
Dartdart
import '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:

js
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" — ويُراسَل جهة اتصال التاجر. يتوقّف الإرسال إلى النقطة المعطّلة؛ وتبقى الأحداث مسجّلة ويمكن إعادة إرسالها بعد إعادة التفعيل:

إعادة التفعيلbash
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).

الأحداث والنقاط لكل وضع: مفتاح الاختبار يسجّل نقاط اختبار ويستقبل أحداث اختبار؛ سجّل نقطة إنتاج بمفتاح الإنتاج قبل الانتقال.

تدوير السرّ#

bash
curl -X POST https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ/rotate_secret \
  -H "Authorization: Bearer $POVITO_SECRET_KEY"

طوال الساعات الأربع والعشرين التالية يُوقَّع كل إرسال بـ السرّين — تحمل الترويسة قيمتي v1= — فتنشر السرّ الجديد دون فجوة. دوال التحقّق أعلاه تقبل أي v1 مطابقة أصلاً.

أداة الاختبار#

bash
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 — مفيد للمطابقة ولتدارك ما فات بعد انقطاع.

bash
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.

إعادة الإرسال#

bash
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؛ وجلب الجلسة قبل التصرّف.
  • نقطة لكل وضع؛ أبقِ السرّ خارج المستودع؛ ودوّره إن تسرّب.