écrits/tutorial/2026/08
Tutorial19 août 2026·28 min

Intégrer Tamara BNPL dans un checkout saoudien avec TypeScript : sessions, webhooks et capture

Construire une intégration directe avec l'API Tamara en TypeScript : vérification d'éligibilité pré-paiement avec fallback à 200ms, création de sessions de paiement, cycle de vie authorize-capture avec quatre fenêtres d'expiration, vérification des webhooks HS256, capture partielle et remboursements simplifiés.

Tamara est le principal fournisseur de paiement fractionné (buy-now-pay-later) en Arabie Saoudite — agréé par la SAMA, conforme à la charia, et intégré dans les checkouts des grandes enseignes comme Jarir et noon. Sur Shopify ou Salla, il suffit d'activer un interrupteur. Sur une stack propriétaire, vous devez intégrer l'API directement, et c'est là que les difficultés apparaissent : un cycle de vie de commande avec quatre fenêtres d'expiration distinctes, une étape d'autorisation facile à manquer jusqu'à ce que vos commandes expirent silencieusement, et un token webhook que la plupart des intégrations ne vérifient jamais.

Ce tutoriel construit l'intégration directe en TypeScript, de bout en bout. Il accompagne notre guide des passerelles de paiement saoudiennes : mada, Moyasar et Tabby, qui couvre les cartes mada, Moyasar et Tabby — lisez celui-ci pour la gestion sécurisée des montants en halalas et la plomberie idempotente des webhooks ; ce tutoriel-ci approfondit tout ce qui est spécifique à Tamara.

Ce que vous allez construire

Un module client Tamara typé ainsi que les deux endpoints HTTP dont votre application a besoin :

  • tamara.ts — client API : vérification d'éligibilité, création de session de paiement, autorisation, capture, remboursement
  • POST /api/checkout/tamara — crée une session et redirige le client vers le checkout hébergé par Tamara
  • POST /api/webhooks/tamara — vérifie le JWT tamaraToken et pilote la machine d'état des commandes

Tout cible la sandbox sur https://api-sandbox.tamara.co et bascule en production en changeant une seule variable d'environnement.

Prérequis

  • Node.js 20+ et un projet TypeScript (n'importe quel framework ; les exemples utilisent le fetch natif et des handlers Request/Response standards)
  • Un compte marchand Tamara avec un token API sandbox depuis le Portail Partenaires (Paramètres, puis Tokens API, puis Générer un nouveau token)
  • Votre token de notification depuis le même portail — nécessaire à l'étape 5 pour vérifier les webhooks
  • Le package jose pour la vérification JWT : npm install jose

Définissez trois variables d'environnement :

TAMARA_API_URL=https://api-sandbox.tamara.co
TAMARA_API_TOKEN=eyJ...        # depuis le Portail Partenaires
TAMARA_NOTIFICATION_TOKEN=...  # token distinct, utilisé uniquement pour vérifier les webhooks

Les deux tokens ne sont pas interchangeables. Le token API authentifie vos appels vers Tamara. Le token de notification vérifie les appels de Tamara vers vous. Envoyer le token de notification comme Bearer token à l'API renvoie des 401 qui ressemblent à une clé expirée.

Étape 1 : Le squelette client et la convention des montants

Chaque requête Tamara est authentifiée avec Authorization: Bearer et votre token API. Commencez par un wrapper typé léger :

// tamara.ts
const BASE = process.env.TAMARA_API_URL!;
const TOKEN = process.env.TAMARA_API_TOKEN!;
 
export interface TamaraAmount {
  amount: number;      // unités décimales principales : 149,50 SAR s'envoie comme 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>;
}

Notez le type de montant. Si vous avez suivi le tutoriel mada/Moyasar/Tabby, vous connaissez déjà le piège : Moyasar veut des halalas entiers (14950), Tabby veut une chaîne décimale ("149.50"), et Tamara veut un nombre décimal (149.5). Trois passerelles, trois conventions, toutes dans un champ appelé amount. Conservez vos montants internes en halalas entiers et convertissez uniquement à la frontière :

/** Convertit des halalas entiers vers le format décimal attendu par Tamara. */
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 };
}

Étape 2 : Vérification d'éligibilité pré-paiement — la règle des 200ms

Comme tout fournisseur BNPL, Tamara évalue l'acheteur, pas la carte. Elle expose un pré-contrôle vous permettant de décider d'afficher ou non l'option Tamara, en fonction des enregistrements de refus actifs du client :

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 {
    // Recommandation officielle Tamara : en cas de timeout ou d'erreur, affichez Tamara quand même.
    return true;
  } finally {
    clearTimeout(timer);
  }
}

Deux points de production à intérioriser :

  1. Le timeout à 200ms avec un fallback permissif est la recommandation officielle de Tamara, pas un hack. Une vérification d'éligibilité lente ne doit jamais ralentir l'affichage de votre checkout ; si la réponse n'arrive pas, affichez l'option.
  2. Si vous omettez le numéro de téléphone, le client est traité comme éligible. Le pré-contrôle vaut ce que vaut l'identité que vous lui passez. Appelez-le après avoir collecté le numéro de téléphone, pas avant.

Ce contrôle réduit le taux de refus visible, mais ne l'élimine pas — la souscription finale se fait sur la page hébergée de Tamara. Votre chemin de repli vers la carte doit toujours exister.

Étape 3 : Créer la session de paiement

L'appel principal est POST /checkout. Il prend votre commande complète — articles, consommateur, adresses, URLs de retour — et renvoie une checkout_url hébergée vers laquelle rediriger le client :

export interface CheckoutPayload {
  order_reference_id: string;      // votre ID de commande — doit être unique par tentative
  total_amount: TamaraAmount;
  shipping_amount: TamaraAmount;
  tax_amount: TamaraAmount;
  description: string;             // 256 caractères maximum
  country_code: 'SA';
  payment_type: 'PAY_BY_INSTALMENTS';
  instalments: number;             // ex. 4
  locale?: string;                 // 'ar_SA' ou 'en_US'
  items: Array<{
    reference_id: string;
    type: string;                  // ex. '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;      // ID Tamara — conservez-le, chaque appel ultérieur en a besoin
  checkout_id: string;
  status: string;
  checkout_url: string;  // redirigez le client ici
}
 
export function createCheckoutSession(payload: CheckoutPayload) {
  return tamaraFetch<CheckoutResponse>('/checkout', {
    method: 'POST',
    body: JSON.stringify(payload),
  });
}

Persistez le order_id retourné par rapport à votre commande avant de rediriger. Le webhook de l'étape 5 identifiera la commande par les deux identifiants ; si vous n'en avez stocké qu'un, vous devrez réconcilier manuellement.

Les URLs de retour méritent un avertissement : l'URL success n'est pas une confirmation de paiement. C'est une navigation navigateur qui peut ne jamais se déclencher (onglet fermé) ou se déclencher faussement (URL rejouée). Le webhook est la source de vérité ; la page de succès doit afficher "confirmation de votre commande en cours…" jusqu'à ce que votre backend ait traité order_approved.

Étape 4 : Le cycle de vie — quatre horloges tournent

Les états de commande Tamara progressent : new, puis approved, puis authorised, puis fully_captured ou partially_captured, avec declined, expired et canceled comme sorties. Ce que les noms d'état ne montrent pas, c'est que chaque transition a sa propre échéance :

FenêtreRègle
30 minutesLe client doit compléter le paiement après la création de la session, sinon la commande expire
72 heuresUne commande approuvée doit atteindre authorised, sinon elle expire
90 joursUne commande autorisée doit être capturée ou annulée
21 joursSi vous n'avez pas capturé d'ici là, Tamara capture automatiquement la commande pour vous

La transition qui piège les nouvelles intégrations est approved vers authorised. L'approbation signifie que le client a terminé sur la page Tamara ; l'autorisation est votre accusé de réception que vous avez reçu ce fait et avez l'intention d'exécuter. Sauf si votre compte a l'autorisation automatique activée, vous devez l'appeler explicitement — et l'endroit documenté pour le faire est votre handler webhook, à la réception de 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',
  });
}

Vérifiez auto_captured dans la réponse. Certaines configurations de compte capturent à l'autorisation ; si ce flag est vrai, sautez l'étape 6 pour cette commande, sinon vous tenterez une double capture.

La fenêtre de 72 heures est le tueur silencieux. Si votre endpoint webhook est hors ligne pendant un week-end et que vous n'autorisez jamais, les commandes approuvées expirent — le client croit avoir payé, et vous n'avez pas de commande. Surveillez les commandes bloquées en approved, et réconciliez quotidiennement avec GET /orders/{order_id} de la même façon que le tutoriel de réconciliation des paiements traite chaque passerelle : les enregistrements du processeur sont la vérité, les vôtres sont l'hypothèse.

Étape 5 : Webhooks — vérifiez toujours le tamaraToken

Enregistrez votre URL de webhook dans le Portail Partenaires (Paramètres, puis Paramètres généraux, puis Webhooks — HTTPS requis). Tamara vous notifie de order_approved (obligatoire), order_declined, order_authorised, order_canceled, order_captured, order_refunded et order_expired.

Chaque notification porte un tamaraToken — un JWT signé avec HS256 en utilisant votre token de notification — livré à la fois comme paramètre de requête et comme header Authorization: Bearer. Un endpoint webhook non vérifié est une API non authentifiée qui marque les commandes comme payées ; la vérification prend quatre lignes avec 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;
 
  // Idempotence : traitez chaque (order_id, event_type) exactement une fois.
  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);      // Étape 4 — ne pas sauter
      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 });
}

Le garde d'idempotence n'est pas optionnel. Les webhooks se réessaient, et order_approved arrivant deux fois ne doit pas autoriser deux fois ni confirmer la commande en double. Le pattern — stocker une clé d'événement traité, retourner 200 en cas de replay — est le même que celui utilisé dans le tutoriel passerelles pour Moyasar et Tabby, donc les trois fournisseurs peuvent partager une seule implémentation.

Étape 6 : Capture à l'expédition

Comme Tabby, Tamara sépare autorisation et capture pour que l'argent circule au moment où vous expédiez, pas quand le client clique. La capture prend l'ID de commande, un montant (capture partielle supportée) et — spécificité de Tamara parmi les passerelles saoudiennes — les informations d'expédition sont obligatoires :

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,
    }),
  });
}

Souvenez-vous de l'horloge des 21 jours de l'étape 4 : si vous n'appelez jamais ceci, Tamara capture le montant complet automatiquement. Ce comportement par défaut est commerçant-friendly en surface et dangereux dessous — si la commande a été annulée dans votre système mais que vous n'avez pas annulé chez Tamara (POST /orders/{order_id}/cancel), une auto-capture facturera un client que vous n'avez jamais livré. Les annulations doivent parvenir à Tamara, pas seulement à votre base de données.

Étape 7 : Remboursements

L'endpoint de remboursement simplifié prend l'ID de commande dans le chemin et supporte les remboursements partiels. Le champ comment est obligatoire et apparaît dans l'historique des transactions de la commande — écrivez quelque chose qu'un agent support comprendra un mois plus tard :

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,
    }),
  });
}

Stockez le refund_id retourné à côté de votre propre enregistrement de remboursement. Quand le webhook order_refunded arrive, faites correspondre par lui — les remboursements initiés depuis le Portail Partenaires par un humain produisent aussi des webhooks, et votre handler doit gérer les remboursements qu'il n'a pas initiés.

Tester votre implémentation

Pointez TAMARA_API_URL vers https://api-sandbox.tamara.co avec votre token sandbox et parcourez le cycle de vie complet :

  1. Éligibilité : appelez le pré-contrôle avec et sans numéro de téléphone ; confirmez que le cas sans téléphone retourne éligible.
  2. Chemin heureux : créez une session, complétez le checkout sur la page sandbox (le guide de test KSA dans la doc Tamara liste les numéros de téléphone et OTP de test), recevez order_approved, autorisez, capturez avec les infos d'expédition, puis remboursez la moitié et confirmez partially_refunded.
  3. Sécurité webhook : faites un POST vers votre webhook sans token, avec un token quelconque, et avec un token signé avec la mauvaise clé — les trois doivent retourner 401 sans rien changer.
  4. Replay : livrez la même charge utile order_approved deux fois ; la deuxième doit retourner 200 sans ré-autoriser.
  5. Expiration : créez une session, ne complétez rien, et confirmez que votre système gère order_expired après la fenêtre de 30 minutes en libérant le stock.

Dépannage

401 sur chaque appel API. Vous envoyez probablement le token de notification au lieu du token API, ou un token de production contre la sandbox. Les deux environnements ont des tokens distincts.

Commandes bloquées en approved puis expired. Votre handler webhook n'appelle pas authorize, ou le webhook n'arrive jamais. Vérifiez la configuration webhook dans le Portail Partenaires et souvenez-vous de l'échéance des 72 heures.

La vérification de signature webhook échoue toujours. Vérifiez contre le token de notification, pas le token API, et confirmez HS256. Si vous avez copié le token depuis le portail, vérifiez les espaces de fin.

Des captures prélevées pour des commandes annulées. L'auto-capture à 21 jours s'est déclenchée. Annulez les commandes chez Tamara via son endpoint d'annulation au moment où elles sont annulées en interne — un flag en base de données de votre côté est invisible pour Tamara.

Montants décalés d'un facteur 100. Quelque chose en amont a passé des halalas directement dans un champ montant Tamara. Faites passer chaque montant par halalasToTamara et typez vos montants internes pour que le compilateur l'attrape.

Prochaines étapes

Conclusion

Une intégration directe Tamara c'est quatre appels API et un webhook — le code n'est pas la partie difficile. La partie difficile, c'est respecter le cycle de vie : pré-contrôle avec un fallback rapide, autorisation à l'approbation avant que l'horloge des 72 heures s'écoule, capture à l'expédition avant que l'auto-capture à 21 jours ne le fasse pour vous, et ne jamais faire confiance à un webhook que vous n'avez pas vérifié contre le token de notification. Faites ces quatre points correctement et la machine d'état s'occupe d'elle-même.

Si vous intégrez Tamara — ou la gérez en parallèle avec mada, Tabby et un ledger de règlement qui doit s'équilibrer — parlez-nous. Nous construisons et auditons des intégrations de paiement pour le marché saoudien, et une revue d'une heure de votre machine d'état de commandes coûte moins cher qu'un week-end de commandes bloquées en approved.