← كل المقالات

منع تكرار Webhooks في Laravel بطريقة آمنة

امنع تكرار أحداث Webhook في Laravel بالتحقق من التوقيع وقيد فريد وسجل وارد دائم وطابور وآثار جانبية آمنة عند إعادة المحاولة.

يبني بكري عبدالسلام مواقع الويب والتطبيقات والتكاملات ومنتجات ووردبريس، ويوثق مركز بكري التقني القرارات التقنية وراء هذا العمل.

الخلاصة

اعتبر وصول Webhook مرة أخرى سلوكاً طبيعياً: تحقق من المصدر، احجز Event ID بقيد Unique داخل قاعدة البيانات، أعد الرد سريعاً، واجعل كل أثر جانبي آمناً عند Retry.

تعيد خدمات Webhooks إرسال الحدث إذا تأخرت نقطة الاستقبال أو أعادت خطأ. وقد ينفذ الطلب الأول بنجاح ثم تضيع استجابتك في الشبكة، فيصل الحدث نفسه مرة أخرى. هذا سلوك متوقع يجب أن يصممه التطبيق، لا حالة نادرة يتجاهلها.

التصميم الصحيح لا يحاول منع الـRetry؛ بل يضمن أن تكرار الطلب لا يكرر النتيجة التجارية.

اختر مفتاحاً ثابتاً لمنع التكرار

استخدم Event ID أو Delivery ID التي ترسلها الخدمة. لا تنشئ UUID جديداً داخل Endpoint لأن كل محاولة ستحصل على UUID مختلف.

غالباً يكون المفتاح:

provider + provider_account + event_id

أضف Account أو Tenant إذا لم تكن المعرفات فريدة عالمياً. إن لم توفر الخدمة معرفاً ثابتاً، ابنِ مفتاحاً موثقاً من حقول أعمال لا تتغير. Hash للـRaw Body يفيد فقط عندما تكون الإعادات متطابقة Byte-by-Byte.

منع صفين متكررين ليس كل Idempotency؛ يجب أن تكون تحديثات قاعدة البيانات والرسائل والمدفوعات والطلبات الخارجية آمنة أيضاً.

تحقق من التوقيع أولاً

اقرأ Raw Body وتحقق من Signature قبل الوثوق بـEvent ID. إذا خزنت المعرف أولاً يستطيع مهاجم إرسال ID حقيقية وحجزها قبل وصول الطلب الصحيح.

اتبع Algorithm والـHeader والـTimestamp الخاصة بكل Provider. يشرح التحقق من توقيع Webhook في PHP وLaravel الترتيب الآمن.

بعد نجاح التحقق افحص JSON Schema ونوع الحدث والحقول المطلوبة.

أنشئ جدولاً دائماً للأحداث الواردة

استخدم Unique Constraint في قاعدة البيانات:

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 50);
    $table->string('provider_account', 100)->default('');
    $table->string('event_id', 191);
    $table->string('event_type', 100);
    $table->string('status', 30)->default('received');
    $table->unsignedInteger('attempts')->default(0);
    $table->json('payload');
    $table->text('last_error')->nullable();
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();

    $table->unique(
        ['provider', 'provider_account', 'event_id'],
        'webhook_events_delivery_unique'
    );
});

قد تحتوي Payload على بيانات شخصية أو أسرار. خزن ما تحتاجه فقط، وحدد Encryption وAccess وRetention.

هذا الشكل غير كافٍ:

if (! WebhookEvent::where('event_id', $id)->exists()) {
    WebhookEvent::create([...]);
}

طلبان متزامنان قد يتجاوزان exists() قبل Insert. القيد داخل Database هو الحماية النهائية من Race Condition.

احجز الحدث داخل عملية ذرية

بعد التحقق من Signature حاول إنشاء السجل. إذا حدث Unique Violation أعد استجابة نجاح لأن الحدث تم قبوله سابقاً:

try {
    $event = DB::transaction(function () use ($provider, $account, $id, $type, $payload) {
        return WebhookEvent::create([
            'provider' => $provider,
            'provider_account' => $account,
            'event_id' => $id,
            'event_type' => $type,
            'payload' => $payload,
        ]);
    });
} catch (QueryException $exception) {
    if (! isUniqueConstraintViolation($exception)) {
        throw $exception;
    }

    return response()->json(['status' => 'already_received'], 200);
}

ProcessWebhookEvent::dispatch($event->id)->afterCommit();

return response()->json(['status' => 'accepted'], 202);

نفذ isUniqueConstraintViolation() وفق Database Driver، ولا تعتبر كل QueryException تكراراً.

أعد الرد بعد التخزين الدائم مباشرة. تنفيذ العمل الثقيل داخل HTTP Request يزيد احتمالية Retry ويستهلك Web Workers.

اجعل مهمة الطابور آمنة عند إعادة التنفيذ

اقرأ سجل Inbox مع Lock داخل Transaction، واخرج إذا كانت الحالة processed:

DB::transaction(function () use ($eventId) {
    $event = WebhookEvent::query()
        ->lockForUpdate()
        ->findOrFail($eventId);

    if ($event->status === 'processed') {
        return;
    }

    applyBusinessChange($event);

    $event->update([
        'status' => 'processed',
        'processed_at' => now(),
        'last_error' => null,
    ]);
});

يمكن إرجاع تغييرات Database داخل Transaction، لكن HTTP Call خارجي لا يرجع معها. أرسل للخدمة الخارجية Idempotency Key ثابتاً مثل:

webhook-event-id + action-name

للرسائل يمكن استخدام Outbox Table مع Unique Constraint على (event_id, action). استخدام ShouldBeUnique يقلل العمل المتكرر، لكنه لا يغني عن سجل الأعمال الدائم لأن Locks تنتهي وقد يتم مسح Cache.

تعامل مع اختلاف الترتيب وإعادة التشغيل

قد تصل Eventان مختلفتان لنفس Object بترتيب عكسي. Event ID تمنع التكرار لكنها لا تحل ترتيب الحالات.

استخدم Sequence Number موثوق إن توفر، أو اجلب Current State من Provider عندما يكون ذلك أكثر أماناً. حدد انتقالات الحالة المسموحة حتى لا يعيد حدث قديم Order من paid إلى pending.

أنشئ أمر Replay تشغيلياً يعيد معالجة نفس السجل ولا ينشئ أثراً جديداً. سجل attempts وlast_error وحالة received/processing/failed/processed.

اختبر حالات الفشل الحقيقية

اختبر:

  1. نفس الحدث مرتين بالتتابع.
  2. نفس الحدث بطلبين متزامنين.
  3. انهياراً بعد Commit وقبل HTTP Response.
  4. Retry بعد Timeout في API خارجية.
  5. حدثين مختلفين لنفس الكيان بترتيب عكسي.
  6. Signature غير صالحة تحمل Event ID حقيقية.

تحقق من النتيجة التجارية: Charge واحدة أو Email واحدة أو تحديث واحد، لا من عدد صفوف Inbox فقط.

أخطاء شائعة

لا تعتمد على Cache TTL وحدها، ولا تخزن ID قبل التحقق من المصدر، ولا تعد 200 قبل التخزين، ولا تسجل Payload الحساسة، ولا تفترض أن Unique Job تعني Idempotency كاملة.

ابدأ من بنية Webhooks موثوقة وشغّل المعالجة وفق دليل Laravel Queue.

أسئلة شائعة

هل أعيد استجابة نجاح للحدث المكرر؟

نعم، بعد نجاح Signature Verification وتأكد Inbox الدائمة أن Event قُبلت سابقاً. تمنع الاستجابة الناجحة Provider من Retry غير ضرورية.

هل يكفي مفتاح Redis لمنع التكرار؟

تقلل التزامن لكنها ليست دائماً سجل أعمال دائم؛ قد تنتهي أو تُحذف. استخدم Unique Constraint في Database عندما يجب أن تبقى النتيجة قابلة للتدقيق.

مراجع رسمية

لديك سؤال عن هذا الدليل أو فكرة لتعاون تقني؟ تواصل مع بكري عبر المركز التقني.

نهاية الملاحظة.