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, remboursementPOST /api/checkout/tamara— crée une session et redirige le client vers le checkout hébergé par TamaraPOST /api/webhooks/tamara— vérifie le JWTtamaraTokenet 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
fetchnatif 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
josepour 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 webhooksLes 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 :
- 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.
- 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être | Règle |
|---|---|
| 30 minutes | Le client doit compléter le paiement après la création de la session, sinon la commande expire |
| 72 heures | Une commande approuvée doit atteindre authorised, sinon elle expire |
| 90 jours | Une commande autorisée doit être capturée ou annulée |
| 21 jours | Si 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 avecGET /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 :
- É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.
- 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 confirmezpartially_refunded. - 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.
- Replay : livrez la même charge utile
order_approveddeux fois ; la deuxième doit retourner 200 sans ré-autoriser. - Expiration : créez une session, ne complétez rien, et confirmez que votre système gère
order_expiredaprè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
- Ajoutez le paiement par carte en complément du BNPL avec le guide d'intégration mada, Moyasar et Tabby — les refus Tamara ont besoin d'un chemin de repli carte.
- Branchez les commandes capturées dans un job de réconciliation quotidien pour que les rapports de règlement Tamara et votre ledger ne dérivent pas silencieusement.
- Si vous facturez des entreprises saoudiennes, connectez le même pipeline de commandes à la facturation électronique ZATCA Phase 2.
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.