الكتابات/tutorial/2026/08
Tutorial19 أغسطس 2026·28 دقيقة

دمج تمارا للدفع بالتقسيط في صفحة الدفع السعودية باستخدام TypeScript: الجلسات والـ Webhooks والاستيفاء

بناء دمج مباشر مع Tamara API بـ TypeScript: التحقق من الأهلية قبل الدفع بنافذة 200ms، إنشاء جلسات الدفع، دورة حياة الطلب (الموافقة-التفويض-الاستيفاء) بنوافذ انتهاء أربع، التحقق من Webhooks بـ HS256، الاستيفاء الجزئي والاسترداد المبسط.

تمارا هي أكبر مزود لخدمة الشراء الآن والدفع لاحقاً في المملكة العربية السعودية — مرخصة من مؤسسة النقد العربي السعودي (ساما)، متوافقة مع الشريعة الإسلامية، ومدمجة في صفحات الدفع لدى كبرى المتاجر من جرير إلى نون. إن كنت تعمل على متجر Shopify أو سلة فالدمج لا يتعدى ضغطة زر. أما إن كنت تدير بنيتك التقنية الخاصة، فعليك دمج الـ API مباشرة، وهنا تظهر التعقيدات: دورة حياة طلب تضم أربع نوافذ انتهاء مختلفة، وخطوة تفويض يسهل تجاهلها حتى تنتهي صلاحية طلباتك بصمت، ورمز Webhook لا تتحقق منه معظم عمليات الدمج أبداً.

يبني هذا الدرس الدمج المباشر بـ TypeScript من البداية حتى النهاية. وهو مكمّل لـ دليل بوابات الدفع السعودية: مدى وموازر وتابي، الذي يغطي بطاقات مدى وموازر وتابي — اقرأ ذلك الدليل للاطلاع على معالجة المبالغ بالهلالة وبنية Webhooks المتسقة؛ هذا الدرس يتعمق في كل ما يخص تمارا تحديداً.

ما ستبنيه

وحدة عميل تمارا المكتوبة بأنواع TypeScript بالإضافة إلى نقطتي HTTP اللازمتين لتطبيقك:

  • tamara.ts — عميل الـ API: التحقق من الأهلية المسبق، إنشاء جلسة الدفع، التفويض، الاستيفاء، الاسترداد
  • POST /api/checkout/tamara — ينشئ جلسة ويعيد توجيه العميل إلى صفحة الدفع المستضافة لدى تمارا
  • POST /api/webhooks/tamara — يتحقق من JWT بنوع tamaraToken ويدير آلة حالة الطلب

كل شيء يستهدف بيئة الاختبار على https://api-sandbox.tamara.co وينتقل للإنتاج بتغيير متغير بيئة واحد.

المتطلبات المسبقة

  • Node.js 20+ ومشروع TypeScript (أي إطار عمل؛ الأمثلة تستخدم fetch الأصلي ومعالجات Request/Response القياسية)
  • حساب تاجر تمارا مع رمز API لبيئة الاختبار من بوابة الشركاء (الإعدادات، ثم رموز API، ثم إنشاء رمز جديد)
  • رمز الإشعارات من البوابة ذاتها — تحتاجه في الخطوة الخامسة للتحقق من Webhooks
  • حزمة jose للتحقق من JWT: npm install jose

حدد ثلاثة متغيرات بيئة:

TAMARA_API_URL=https://api-sandbox.tamara.co
TAMARA_API_TOKEN=eyJ...        # من بوابة الشركاء
TAMARA_NOTIFICATION_TOKEN=...  # رمز منفصل، يُستخدم فقط للتحقق من Webhooks

الرمزان غير قابلَين للتبادل. رمز الـ API يصادق على طلباتك أنت إلى تمارا. رمز الإشعارات يتحقق من طلبات تمارا إليك. إرسال رمز الإشعارات كـ Bearer token إلى الـ API سيعيد أخطاء 401 تبدو كأنها مفتاح منتهي الصلاحية.

الخطوة الأولى: هيكل العميل واتفاقية المبالغ

كل طلب تمارا يصادَق عليه بـ Authorization: Bearer ورمز الـ API. ابدأ بغلاف مكتوب بأنواع خفيف:

// tamara.ts
const BASE = process.env.TAMARA_API_URL!;
const TOKEN = process.env.TAMARA_API_TOKEN!;
 
export interface TamaraAmount {
  amount: number;      // وحدات رئيسية عشرية: 149.50 ريال تُرسَل كـ 149.5
  currency: 'SAR' | 'AED' | 'BHD' | 'KWD' | 'OMR';
}
 
async function tamaraFetch<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      'Authorization': `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
      ...init?.headers,
    },
  });
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Tamara ${path} failed: ${res.status} ${body}`);
  }
  return res.json() as Promise<T>;
}

لاحظ نوع المبلغ. إن اتبعت دليل مدى/موازر/تابي فأنت تعرف المشكلة بالفعل: موازر تريد هلالات صحيحة (14950)، وتابي تريد سلسلة عشرية ("149.50")، وتمارا تريد رقماً عشرياً (149.5). ثلاث بوابات وثلاث اتفاقيات في حقل اسمه amount. احفظ مبالغك داخلياً بالهلالات الصحيحة وحوّلها فقط عند الحدود:

/** تحويل الهلالات الصحيحة إلى الصيغة العشرية التي تتوقعها تمارا. */
export function halalasToTamara(halalas: number, currency: TamaraAmount['currency'] = 'SAR'): TamaraAmount {
  if (!Number.isInteger(halalas)) throw new Error(`Non-integer halalas: ${halalas}`);
  return { amount: halalas / 100, currency };
}

الخطوة الثانية: التحقق من الأهلية قبل الدفع — قاعدة 200ms

كأي مزود BNPL، تمارا تُقيّم المشتري وليس البطاقة. توفر فحصاً مسبقاً تستطيع من خلاله تحديد ما إذا كنت ستعرض خيار تمارا أصلاً، بناءً على سجلات الرفض النشطة للعميل:

interface EligibilityResponse {
  is_eligible: boolean;
}
 
export async function checkEligibility(
  order: TamaraAmount,
  phoneNumber?: string,
  email?: string,
): Promise<boolean> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 200);
  try {
    const res = await tamaraFetch<EligibilityResponse>('/pre-checkout/v1/eligibility', {
      method: 'POST',
      body: JSON.stringify({
        order: { amount: order.amount, currency: order.currency },
        customer: { phone_number: phoneNumber, email },
      }),
      signal: controller.signal,
    });
    return res.is_eligible;
  } catch {
    // توصية تمارا الرسمية: عند انتهاء المهلة أو الخطأ، اعرض تمارا على أي حال.
    return true;
  } finally {
    clearTimeout(timer);
  }
}

تفصيلان للإنتاج يستحقان الاستيعاب:

  1. مهلة الـ 200ms مع التراجع المتساهل هي توصية تمارا الرسمية، وليست حيلة. فحص الأهلية البطيء يجب ألا يُبطئ عرض صفحة الدفع؛ إن لم تصل الإجابة في الوقت المحدد، اعرض الخيار.
  2. إن لم ترسل رقم الهاتف، يُعامَل العميل كمؤهَّل. الفحص المسبق بقدر جودة الهوية التي تمرر إليه. استدعِه بعد جمع رقم الهاتف، لا قبله.

هذا الفحص يقلل معدل الرفض المرئي، لكنه لا يلغيه — التقييم النهائي يتم على صفحة تمارا المستضافة. مسار التراجع إلى البطاقة يجب أن يظل موجوداً.

الخطوة الثالثة: إنشاء جلسة الدفع

الاستدعاء الأساسي هو POST /checkout. يأخذ طلبك كاملاً — العناصر، المستهلك، العناوين، روابط الإعادة — ويعيد checkout_url مستضافة تعيد توجيه العميل إليها:

export interface CheckoutPayload {
  order_reference_id: string;      // معرّف طلبك — يجب أن يكون فريداً لكل محاولة
  total_amount: TamaraAmount;
  shipping_amount: TamaraAmount;
  tax_amount: TamaraAmount;
  description: string;             // 256 حرفاً كحد أقصى
  country_code: 'SA';
  payment_type: 'PAY_BY_INSTALMENTS';
  instalments: number;             // مثلاً 4
  locale?: string;                 // 'ar_SA' أو 'en_US'
  items: Array<{
    reference_id: string;
    type: string;                  // مثلاً 'Physical'
    name: string;
    sku: string;
    quantity: number;
    total_amount: TamaraAmount;
  }>;
  consumer: {
    first_name: string;
    last_name: string;
    phone_number: string;          // 9665xxxxxxxx
    email?: string;
  };
  shipping_address: {
    first_name: string;
    last_name: string;
    line1: string;
    city: string;
    country_code: 'SA';
  };
  merchant_url: {
    success: string;
    failure: string;
    cancel: string;
  };
}
 
interface CheckoutResponse {
  order_id: string;      // معرّف تمارا — احتفظ به، كل استدعاء لاحق يحتاجه
  checkout_id: string;
  status: string;
  checkout_url: string;  // وجّه العميل إلى هنا
}
 
export function createCheckoutSession(payload: CheckoutPayload) {
  return tamaraFetch<CheckoutResponse>('/checkout', {
    method: 'POST',
    body: JSON.stringify(payload),
  });
}

احتفظ بـ order_id المُعاد مقابل طلبك قبل إعادة التوجيه. سيعرّف الـ Webhook في الخطوة الخامسة الطلب بكلا المعرّفَين؛ إن حفظت طرفاً واحداً فقط ستحتاج للتطابق يدوياً لاحقاً.

روابط الإعادة تستحق تحذيراً مستخلصاً من التجربة: رابط success ليس تأكيد الدفع. هو تنقل متصفح قد لا يحدث أبداً (إغلاق التبويب) أو يحدث بشكل زائف (إعادة استخدام الرابط). Webhook هو المصدر الحقيقي للحقيقة؛ صفحة النجاح يجب أن تعرض "نؤكد طلبك…" حتى تعالج الخلفية حدث order_approved.

الخطوة الرابعة: دورة الحياة — أربعة مؤقتات تعمل

حالات طلب تمارا تسير: new، ثم approved، ثم authorised، ثم fully_captured أو partially_captured، مع مخارج declined وexpired وcanceled. ما لا تكشفه أسماء الحالات أن لكل انتقال موعداً نهائياً خاصاً:

النافذةالقاعدة
30 دقيقةيجب أن يكمل العميل الدفع بعد إنشاء الجلسة أو ينتهي الطلب
72 ساعةطلب موافق عليه يجب أن يصل إلى authorised أو ينتهي
90 يوماًطلب مفوّض يجب استيفاؤه أو إلغاؤه
21 يوماًإن لم تستوفِ بحلول ذلك الوقت، تستوفي تمارا الطلب تلقائياً

الانتقال الذي يُعثر عمليات الدمج الجديدة هو من approved إلى authorised. الموافقة تعني أن العميل أكمل على صفحة تمارا؛ التفويض هو إقرارك باستلام هذه الحقيقة ونيتك في التنفيذ. ما لم يكن حسابك يدعم التفويض التلقائي، يجب أن تستدعيه صراحةً — والمكان الموثق لذلك هو معالج الـ Webhook عند استلام order_approved:

interface AuthoriseResponse {
  order_id: string;
  status: string;
  order_expiry_time: string;
  payment_type: 'PAY_BY_INSTALMENTS' | 'PAY_NOW';
  auto_captured: boolean;
  authorized_amount: TamaraAmount;
  capture_id?: string;
}
 
export function authoriseOrder(orderId: string) {
  return tamaraFetch<AuthoriseResponse>(`/orders/${orderId}/authorise`, {
    method: 'POST',
  });
}

تحقق من auto_captured في الاستجابة. بعض تكوينات الحسابات تستوفي عند التفويض؛ إن كانت هذه العلامة صحيحة، تجاوز الخطوة السادسة لهذا الطلب وإلا ستحاول استيفاءً مزدوجاً.

نافذة الـ 72 ساعة هي القاتل الصامت. إن كان معالج الـ Webhook لديك معطلاً طوال عطلة نهاية الأسبوع ولم تفوّض أي طلب، ستنتهي صلاحية الطلبات الموافق عليها — العميل يعتقد أنه دفع، وأنت لا تملك طلباً. راقب الطلبات العالقة في حالة approved، وتحقق يومياً بـ GET /orders/{order_id} بنفس الطريقة التي يتعامل بها دليل تسوية المدفوعات مع كل بوابة: سجلات معالج الدفع هي الحقيقة، وسجلاتك هي الفرضية.

الخطوة الخامسة: Webhooks — تحقق دائماً من tamaraToken

سجّل رابط الـ Webhook في بوابة الشركاء (الإعدادات، ثم الإعدادات العامة، ثم Webhooks — يتطلب HTTPS). تمارا ترسل إشعارات لـ order_approved (إلزامي)، order_declined، order_authorised، order_canceled، order_captured، order_refunded وorder_expired.

كل إشعار يحمل tamaraTokenJWT موقَّع بـ HS256 باستخدام رمز الإشعارات — يُسلَّم كمعامل استعلام وكـ Authorization: Bearer header. نقطة Webhook غير محقَّقة هي API مجهول الهوية يضع الطلبات كـ"مدفوعة"؛ التحقق أربعة أسطر مع jose:

// webhook-handler.ts
import { jwtVerify } from 'jose';
import { authoriseOrder } from './tamara';
 
const NOTIFICATION_KEY = new TextEncoder().encode(
  process.env.TAMARA_NOTIFICATION_TOKEN!,
);
 
interface TamaraWebhookEvent {
  order_id: string;
  order_reference_id: string;
  order_number?: string;
  event_type: string;
  data: Record<string, unknown>;
}
 
export async function handleTamaraWebhook(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const token =
    url.searchParams.get('tamaraToken') ??
    req.headers.get('authorization')?.replace(/^Bearer /, '');
 
  if (!token) return new Response('missing token', { status: 401 });
 
  try {
    await jwtVerify(token, NOTIFICATION_KEY, { algorithms: ['HS256'] });
  } catch {
    return new Response('invalid token', { status: 401 });
  }
 
  const event = (await req.json()) as TamaraWebhookEvent;
 
  // الاتساق: معالجة كل (order_id, event_type) مرة واحدة بالضبط.
  if (await alreadyProcessed(event.order_id, event.event_type)) {
    return new Response('ok', { status: 200 });
  }
 
  switch (event.event_type) {
    case 'order_approved':
      await authoriseOrder(event.order_id);      // الخطوة الرابعة — لا تتجاوزها
      await markOrderConfirmed(event.order_reference_id);
      break;
    case 'order_declined':
    case 'order_expired':
      await releaseInventory(event.order_reference_id);
      break;
    case 'order_captured':
      await recordCapture(event.order_reference_id, event.data);
      break;
    case 'order_refunded':
      await recordRefund(event.order_reference_id, event.data);
      break;
  }
 
  return new Response('ok', { status: 200 });
}

ضمان الاتساق ليس اختيارياً. إشعارات الـ Webhook تُعاد المحاولة، وorder_approved الذي يصل مرتين يجب ألا يفوّض مرتين أو يؤكد الطلب مضاعفاً. النمط — تخزين مفتاح الحدث المعالَج، إعادة 200 عند التكرار — هو نفسه المستخدم في دليل البوابات لموازر وتابي، فجميع المزودين الثلاثة يمكنهم مشاركة تنفيذ واحد.

الخطوة السادسة: الاستيفاء عند الشحن

مثل تابي، تمارا تفصل التفويض عن الاستيفاء حتى تنتقل الأموال عند الشحن، لا عند نقر العميل. الاستيفاء يأخذ معرّف الطلب ومبلغاً (الاستيفاء الجزئي مدعوم) ومعلومات الشحن — التي تتفرد بها تمارا مقارنةً بغيرها من بوابات السعودية:

interface CaptureResponse {
  capture_id: string;
  order_id: string;
  status: 'fully_captured' | 'partially_captured';
  captured_amount: TamaraAmount;
}
 
export function captureOrder(
  orderId: string,
  amount: TamaraAmount,
  shipping: { shipped_at: string; shipping_company: string; tracking_number?: string },
) {
  return tamaraFetch<CaptureResponse>('/payments/capture', {
    method: 'POST',
    body: JSON.stringify({
      order_id: orderId,
      total_amount: amount,
      shipping_info: shipping,
    }),
  });
}

تذكر مؤقت الـ 21 يوماً من الخطوة الرابعة: إن لم تستدعِ هذا الاستدعاء أبداً، تستوفي تمارا المبلغ الكامل تلقائياً. هذا الإعداد الافتراضي صديق للتاجر في ظاهره وخطير في جوهره — إن ألغيت الطلب في نظامك دون إلغائه لدى تمارا (POST /orders/{order_id}/cancel)، سيُشحن عميل لم ترسل له شيئاً. الإلغاءات يجب أن تصل إلى تمارا، لا مجرد قاعدة بياناتك.

الخطوة السابعة: الاسترداد

نقطة الاسترداد المبسّطة تأخذ معرّف الطلب في المسار وتدعم الاسترداد الجزئي. حقل comment إلزامي ويظهر في سجل معاملات الطلب — اكتب شيئاً سيفهمه موظف الدعم بعد شهر:

interface RefundResponse {
  order_id: string;
  refund_id: string;
  capture_id: string;
  status: 'fully_refunded' | 'partially_refunded';
  refunded_amount: TamaraAmount;
}
 
export function refundOrder(orderId: string, amount: TamaraAmount, comment: string, merchantRefundId?: string) {
  return tamaraFetch<RefundResponse>(`/payments/simplified-refund/${orderId}`, {
    method: 'POST',
    body: JSON.stringify({
      total_amount: amount,
      comment,
      merchant_refund_id: merchantRefundId,
    }),
  });
}

احتفظ بـ refund_id المُعاد بجانب سجل الاسترداد الخاص بك. حين يصل Webhook بـ order_refunded، طابقه معه — الاستردادات التي يبادر بها موظف دعم من بوابة الشركاء أيضاً تنتج Webhooks، ومعالجك يجب أن يتعامل مع استردادات لم يبادر بها هو.

اختبار التنفيذ

وجّه TAMARA_API_URL إلى https://api-sandbox.tamara.co مع رمز بيئة الاختبار وتتبع دورة الحياة الكاملة:

  1. الأهلية: استدعِ الفحص المسبق مع رقم هاتف وبدونه؛ تأكد أن حالة بدون هاتف ترجع مؤهَّلاً.
  2. المسار الناجح: أنشئ جلسة، أكمل الدفع على صفحة بيئة الاختبار (دليل اختبار KSA في وثائق تمارا يسرد أرقام الهواتف ورموز OTP للاختبار)، استلم order_approved، فوّض، استوفِ مع معلومات الشحن، ثم أعِد استرداد النصف وتأكد من partially_refunded.
  3. أمان الـ Webhook: أرسل إلى Webhook بلا رمز، ورمز عشوائي، ورمز موقَّع بمفتاح خاطئ — الثلاثة يجب أن يعيدوا 401 دون تغيير أي شيء.
  4. التكرار: سلّم نفس حمولة order_approved مرتين؛ المرة الثانية يجب أن تعيد 200 دون إعادة التفويض.
  5. الانتهاء: أنشئ جلسة، لا تكمل شيئاً، وتأكد أن نظامك يتعامل مع order_expired بعد نافذة الـ 30 دقيقة بتحرير المخزون.

استكشاف الأخطاء

401 في كل استدعاء API. على الأرجح ترسل رمز الإشعارات بدلاً من رمز الـ API، أو رمز إنتاج ضد بيئة الاختبار. البيئتان تملكان رموزاً منفصلة.

طلبات عالقة في approved ثم expired. معالج الـ Webhook لا يستدعي التفويض، أو الـ Webhook لا يصل أصلاً. تحقق من تكوين الـ Webhook في بوابة الشركاء وتذكر موعد الـ 72 ساعة.

التحقق من توقيع الـ Webhook يفشل دائماً. تحقق باستخدام رمز الإشعارات، لا رمز الـ API، وتأكد من HS256. إن نسخت الرمز من البوابة، ابحث عن مسافات بيضاء في النهاية.

تُسحب مبالغ من طلبات ملغاة. الاستيفاء التلقائي بعد 21 يوماً فُعّل. ألغِ الطلبات في تمارا بنقطة الإلغاء لديها فور إلغائها داخلياً — علامة في قاعدة البيانات لديك غير مرئية لتمارا.

المبالغ خاطئة بعامل 100. شيء ما مرّر الهلالات مباشرةً إلى حقل مبلغ تمارا. مرّر كل مبلغ عبر halalasToTamara واكتب أنواعاً لمبالغك الداخلية حتى يكتشف المترجم الخطأ.

الخطوات التالية

الخاتمة

الدمج المباشر مع تمارا هو أربعة استدعاءات API ومعالج Webhook — الكود ليس الجزء الصعب. الجزء الصعب هو احترام دورة الحياة: الفحص المسبق بتراجع سريع، التفويض فور الموافقة قبل نفاد مهلة الـ 72 ساعة، الاستيفاء عند الشحن قبل أن يفعل الاستيفاء التلقائي بعد 21 يوماً، وعدم الوثوق بأي Webhook لم تتحقق منه باستخدام رمز الإشعارات. احصل على هذه الأربعة بشكل صحيح وآلة الحالة ستعتني بنفسها.

إن كنت تدمج تمارا — أو تتعامل معها إلى جانب مدى وتابي وسجل تسوية يجب أن يوازن — تحدث إلينا. نبني عمليات دمج الدفع ونراجعها للسوق السعودي، ومراجعة ساعة واحدة لآلة حالة الطلب لديك أرخص من عطلة نهاية أسبوع من الطلبات العالقة في approved.