writing/tutorial/2026/08
TutorialAug 19, 2026·28 min read

Integrate Tamara BNPL into a Saudi Checkout with TypeScript: Sessions, Webhooks and Capture

Build a direct Tamara API integration in TypeScript: pre-checkout eligibility with a 200ms fallback, checkout sessions, the approve-authorise-capture lifecycle with its four expiry windows, HS256 webhook verification, partial capture and simplified refunds.

Tamara is the largest buy-now-pay-later provider in Saudi Arabia — SAMA-licensed, Sharia-compliant, and embedded in checkouts from Jarir to noon. If you run a store on Shopify or Salla, you flip a switch. If you run your own stack, you integrate the API directly, and that is where the sharp edges are: an order lifecycle with four different expiry windows, an authorise step that is easy to skip until it silently expires your orders, and a webhook token that most integrations never verify.

This tutorial builds the direct integration in TypeScript, end to end. It is a companion to our Saudi payment gateway tutorial, which covers mada cards, Moyasar and Tabby — read that one for halala-safe money handling and idempotent webhook plumbing; this one goes deep on everything Tamara-specific.

What You'll Build

A typed Tamara client module plus the two HTTP endpoints your app needs:

  • tamara.ts — API client: eligibility pre-check, checkout session creation, authorise, capture, refund
  • POST /api/checkout/tamara — creates a session and redirects the customer to Tamara's hosted checkout
  • POST /api/webhooks/tamara — verifies the tamaraToken JWT and drives the order state machine

Everything targets the sandbox at https://api-sandbox.tamara.co and moves to production by changing one environment variable.

Prerequisites

  • Node.js 20+ and a TypeScript project (any framework; examples use plain fetch and standard Request/Response handlers)
  • A Tamara merchant account with a sandbox API token from the Partners Portal (Settings, then API Tokens, then Generate new token)
  • Your notification token from the same portal — you need it in Step 5 to verify webhooks
  • The jose package for JWT verification: npm install jose

Set three environment variables:

TAMARA_API_URL=https://api-sandbox.tamara.co
TAMARA_API_TOKEN=eyJ...        # from the Partners Portal
TAMARA_NOTIFICATION_TOKEN=...  # separate token, used only to verify webhooks

The two tokens are not interchangeable. The API token authenticates your calls to Tamara. The notification token verifies Tamara's calls to you. Sending the notification token as a Bearer token to the API returns 401s that look like an expired key.

Step 1: The Client Skeleton and the Amount Convention

Every Tamara request authenticates with Authorization: Bearer and your API token. Start with a thin typed wrapper:

// tamara.ts
const BASE = process.env.TAMARA_API_URL!;
const TOKEN = process.env.TAMARA_API_TOKEN!;
 
export interface TamaraAmount {
  amount: number;      // decimal major units: SAR 149.50 is 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>;
}

Note the amount type. If you followed the mada/Moyasar/Tabby tutorial you already know the trap: Moyasar wants integer halalas (14950), Tabby wants a decimal string ("149.50"), and Tamara wants a decimal number (149.5). Three gateways, three conventions, all in a field called amount. Keep your internal money as integer halalas and convert only at the boundary:

/** Convert integer halalas to the decimal-number form Tamara expects. */
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 };
}

Step 2: Pre-Checkout Eligibility — the 200ms Rule

Like every BNPL provider, Tamara underwrites the shopper, not the card. It exposes a pre-check so you can decide whether to render the Tamara option at all, based on active decline records for that customer:

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 {
    // Tamara's own guidance: on timeout or error, show Tamara anyway.
    return true;
  } finally {
    clearTimeout(timer);
  }
}

Two production details worth internalising:

  1. The 200ms timeout with a permissive fallback is Tamara's official recommendation, not a hack. A slow eligibility check must never slow your checkout render; if the answer does not arrive, show the option.
  2. If you omit the phone number, the customer is treated as eligible. The pre-check is only as good as the identity you pass it. Call it after you have collected the phone number, not before.

This check reduces the visible-rejection rate, but it does not eliminate declines — final underwriting happens on Tamara's hosted page. Your fallback-to-card path still has to exist.

Step 3: Create the Checkout Session

The core call is POST /checkout. It takes your full order — items, consumer, addresses, redirect URLs — and returns a hosted checkout_url to redirect the customer to:

export interface CheckoutPayload {
  order_reference_id: string;      // your order ID — must be unique per attempt
  total_amount: TamaraAmount;
  shipping_amount: TamaraAmount;
  tax_amount: TamaraAmount;
  description: string;             // max 256 chars
  country_code: 'SA';
  payment_type: 'PAY_BY_INSTALMENTS';
  instalments: number;             // e.g. 4
  locale?: string;                 // 'ar_SA' or 'en_US'
  items: Array<{
    reference_id: string;
    type: string;                  // e.g. '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;      // Tamara's ID — persist it, every later call needs it
  checkout_id: string;
  status: string;
  checkout_url: string;  // redirect the customer here
}
 
export function createCheckoutSession(payload: CheckoutPayload) {
  return tamaraFetch<CheckoutResponse>('/checkout', {
    method: 'POST',
    body: JSON.stringify(payload),
  });
}

Persist the returned order_id against your own order before redirecting. The webhook in Step 5 will identify the order by both order_id and your order_reference_id; if you only stored one side you will eventually reconcile by hand.

The redirect URLs deserve a warning learned the hard way in the callback-verification section of the gateway tutorial: the success redirect is not payment confirmation. It is a browser navigation that may never fire (closed tab) or fire falsely (replayed URL). The webhook is the source of truth; the success page should render "confirming your order…" until your backend has processed order_approved.

Step 4: The Lifecycle — Four Clocks Are Ticking

Tamara's order states run new, then approved, then authorised, then fully_captured or partially_captured, with declined, expired and canceled as exits. What the state names do not show is that each transition has its own deadline:

WindowRule
30 minutesCustomer must complete payment after the session is created, or the order expires
72 hoursAn approved order must reach authorised, or it expires
90 daysAn authorised order must be captured or cancelled
21 daysIf you have not captured by then, Tamara auto-captures the order for you

The transition that trips up new integrations is approved to authorised. Approval means the customer finished on Tamara's page; authorisation is your acknowledgement that you received that fact and intend to fulfil. Unless your account has auto-authorisation enabled, you must call it explicitly — and the documented place to do so is your webhook handler, on receipt of 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',
  });
}

Check auto_captured in the response. Some account configurations capture at authorisation time; if that flag is true, skip Step 6 for this order or you will attempt a double capture.

The 72-hour window is the silent killer. If your webhook endpoint is down for a weekend and you never authorise, approved orders expire — the customer believes they paid, and you have no order. Monitor for orders stuck in approved, and reconcile daily with GET /orders/{order_id} the same way the settlement reconciliation tutorial treats every gateway: the processor's records are the truth, yours are the hypothesis.

Step 5: Webhooks — Verify the tamaraToken, Always

Register your webhook URL in the Partners Portal (Settings, then General Settings, then Webhooks — HTTPS required). Tamara notifies you of order_approved (mandatory), order_declined, order_authorised, order_canceled, order_captured, order_refunded and order_expired.

Every notification carries a tamaraToken — a JWT signed with HS256 using your notification token — delivered both as a query parameter and as an Authorization: Bearer header. An unverified webhook endpoint is an unauthenticated API that marks orders as paid; verification is four lines with 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;
 
  // Idempotency: process each (order_id, event_type) exactly once.
  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);      // Step 4 — do not skip
      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 });
}

The idempotency guard is not optional. Webhook deliveries retry, and order_approved arriving twice must not authorise twice or double-confirm the order. The pattern — store a processed-event key, return 200 on replay — is the same one the gateway tutorial uses for Moyasar and Tabby, so all three providers can share one implementation.

Step 6: Capture on Fulfilment

Like Tabby, Tamara separates authorisation from capture so that money moves when you ship, not when the customer clicks. Capture takes the order ID, an amount (partial capture is supported), and — uniquely among Saudi gateways — shipping information is required:

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

Remember the 21-day clock from Step 4: if you never call this, Tamara captures the full amount automatically. That default is merchant-friendly on the surface and dangerous underneath — if the order was actually cancelled in your system but you failed to cancel it with Tamara (POST /orders/{order_id}/cancel), auto-capture charges a customer you never shipped to. Cancellations must reach Tamara, not just your database.

Step 7: Refunds

The simplified refund endpoint takes the order ID in the path and supports partial refunds. The comment is required and lands in the order's transaction history — write something a support agent will understand a month later:

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

Store the returned refund_id next to your own refund record. When the order_refunded webhook arrives, match on it — refunds initiated from the Partners Portal by a human also produce webhooks, and your handler should cope with refunds it did not initiate.

Testing Your Implementation

Point TAMARA_API_URL at https://api-sandbox.tamara.co with your sandbox token and walk the full lifecycle:

  1. Eligibility: call the pre-check with and without a phone number; confirm the no-phone case returns eligible.
  2. Happy path: create a session, complete checkout on the sandbox page (the KSA testing guide in Tamara's docs lists test phone numbers and OTPs), receive order_approved, authorise, capture with shipping info, then refund half and confirm partially_refunded.
  3. Webhook security: POST to your webhook with no token, a garbage token, and a token signed with the wrong key — all three must return 401 and change nothing.
  4. Replay: deliver the same order_approved payload twice; the second must return 200 without re-authorising.
  5. Expiry: create a session, complete nothing, and confirm your system handles order_expired after the 30-minute window by releasing inventory.

Troubleshooting

401 on every API call. You are probably sending the notification token instead of the API token, or a production token against sandbox. The two environments have separate tokens.

Orders stuck in approved and later expired. Your webhook handler is not calling authorise, or the webhook never arrives. Check the Partners Portal webhook configuration and remember the 72-hour deadline.

Webhook signature verification always fails. Verify against the notification token, not the API token, and confirm HS256. If you copied the token from the portal, check for trailing whitespace.

Captures charged for cancelled orders. The 21-day auto-capture fired. Cancel orders with Tamara's cancel endpoint at the moment they are cancelled internally — a database flag on your side is invisible to Tamara.

Amounts off by a factor of 100. Something upstream passed halalas straight into a Tamara amount field. Route every amount through halalasToTamara and type your internal money so the compiler catches it — the technique is in Step 1 of the gateway tutorial.

Next Steps

Conclusion

A direct Tamara integration is four API calls and a webhook — the code is not the hard part. The hard part is respecting the lifecycle: pre-check with a fast fallback, authorise on approval before the 72-hour clock runs out, capture on shipment before the 21-day auto-capture does it for you, and never trust a webhook you have not verified against the notification token. Get those four right and the state machine takes care of itself.

If you are integrating Tamara — or juggling it alongside mada, Tabby and a settlement ledger that has to balance — talk to us. We build and audit payment integrations for the Saudi market, and a one-hour review of your order state machine is cheaper than one weekend of orders stuck in approved.