One GCC Checkout for Tabby, Tamara and Checkout.com: Webhooks and Refunds
Founder & Lead Engineer, RAITHub
To run Tabby, Tamara and Checkout.com in one GCC checkout, put each behind the same provider interface: store money as integer minor units, map each provider's statuses onto one state machine, and treat webhooks as hints to re-fetch, not facts. The providers disagree even on amounts: Tabby takes a decimal string, Tamara a number, and Checkout.com an integer where 10000 means SAR 100.
This is an engineering guide for teams adding buy now, pay later (BNPL, where the customer pays in instalments and the provider pays you) next to card payments in Saudi Arabia and the UAE. Every provider detail below comes from the providers' own documentation, checked on 30 September 2026; re-check it before you build, because payment APIs change. RAITHub has not shipped Tabby, Tamara or Checkout.com integrations. It has shipped Stripe in PropDesk, bKash, Nagad and SSLCommerz in TheSkinProof, the founder's own marketplace venture, and 9 payment gateways in PadhAI. The abstraction problem is the same one.
How do Tabby, Tamara and Checkout.com differ?
Enough that code written for one will break on another. Tabby and Tamara are BNPL providers with their own approval step; Checkout.com is a card acquirer and gateway that also processes mada, the Saudi Central Bank's national payment system (SAMA: mada).
| Aspect | Tabby | Tamara | Checkout.com (cards, mada) |
|---|---|---|---|
| Markets in the docs | KSA and UAE; separate API hosts, api.tabby.sa and api.tabby.ai | Countries SA, AE, BH, KW, OM; currencies SAR, AED, KWD, BHD, OMR | For mada in Saudi Arabia, you first need a merchant ID from a Saudi card acquirer |
| Create a checkout | POST /api/v2/checkout; status created or rejected up front | POST /checkout; returns order_id and checkout_url | A payment request; 3D Secure advised on all card payments in KSA |
| Amount format | String, up to 2 decimals for SAR and AED, 3 for KWD | Number, with a currency, e.g. 300 SAR | Integer in minor units: 10000 is 100.00 SAR; 100000 is 100.000 KWD |
| Statuses | CREATED, AUTHORIZED, CLOSED, REJECTED, EXPIRED | new, approved, authorised, partially or fully captured, partially or fully refunded, canceled, declined, expired | Payment lifecycle events per the webhook docs |
| Capture | Capture as soon as authorised; payout happens only after the payment is closed | Call Authorise Order on the approved event (or use auto-authorisation); uncaptured orders are auto-captured 21 days after authorisation | mada must be captured in full: no partial captures or authorisation-only requests |
| Webhooks | Order not guaranteed, may arrive twice; 1-minute timeout, then up to 4 resends | Events such as order_approved, order_captured, order_refunded | At least once, order may vary; HMAC in the Cko-Signature header; up to 8 retries |
| Refunds | Full or partial; the payment stays CLOSED | Simplified refund, for captured orders | Full or partial |
Sources: Tabby's create a session, payment statuses and webhooks; Tamara's create checkout session, order status flow and webhooks; Checkout.com's mada, amount format, webhooks and refunds pages.
What should one provider interface for BNPL and cards look like?
Small, and shaped around your order, not around any provider's API. The rest of your product talks only to this interface; each provider gets an adapter that translates. A fake adapter implements it too, so the whole checkout runs in tests without a network.
// checkout/types.ts
export type Currency = 'SAR' | 'AED' | 'KWD' | 'BHD' | 'OMR'
/** Decimal places: halalas for SAR, fils for AED, 3 places for KWD, BHD, OMR. */
export const MINOR_DIGITS: Record<Currency, number> = { SAR: 2, AED: 2, KWD: 3, BHD: 3, OMR: 3 }
/** Always an integer in the smallest unit: { minor: 10000, currency: 'SAR' } is SAR 100.00. */
export interface Money { minor: number; currency: Currency }
export type PaymentState = 'pending' | 'authorized' | 'captured' | 'failed' | 'expired' | 'canceled'
export interface Order {
id: string // your order id, sent to the provider as its reference
total: Money
items: { sku: string; name: string; quantity: number; unit: Money }[]
customer: { name: string; email: string; phone: string }
}
/** The provider's current view of one payment. */
export interface Snapshot {
providerRef: string
state: PaymentState
capturedMinor: number
refundedMinor: number
}
export interface CheckoutProvider {
readonly id: 'tabby' | 'tamara' | 'checkout_com'
readonly supportsPartialCapture: boolean
createSession(
order: Order,
urls: { success: string; failure: string; cancel: string },
): Promise<{ providerRef: string; redirectUrl: string } | { rejected: true }>
/** Webhooks only tell you to call this. */
fetchSnapshot(providerRef: string): Promise<Snapshot>
capture(providerRef: string, amount: Money, idempotencyKey: string): Promise<void>
cancel(providerRef: string, idempotencyKey: string): Promise<void>
refund(providerRef: string, amount: Money, idempotencyKey: string): Promise<void>
/** Check authenticity; return the reference to re-fetch, or null to reject. */
verifyWebhook(headers: Headers, rawBody: string): Promise<{ providerRef: string } | null>
}
Three choices in that shape carry most of the value:
- Money is one type. Every amount in your database and business logic is an integer in minor units. Conversion to each provider's format happens only inside its adapter.
rejectedis a normal result, not an error. Tabby can decline a customer at session creation. The checkout should hide that option and offer the others, not show an error page.- Capabilities are declared.
supportsPartialCaptureis false for a mada card, so an order that ships in two parts is charged in full up front for mada and captured in parts only where the provider allows it.
How do you convert amounts for each provider without rounding errors?
Convert at the edge, once, from integers. Floating-point riyals stored in a database eventually produce a total that is one halala off, and a provider that rejects the request or, worse, accepts it.
// checkout/amounts.ts
import { MINOR_DIGITS, type Currency, type Money } from './types'
const scale = (c: Currency) => 10 ** MINOR_DIGITS[c]
// Tabby: a decimal string, e.g. '100.00' (SAR) or '100.000' (KWD)
export const toTabby = (m: Money) => (m.minor / scale(m.currency)).toFixed(MINOR_DIGITS[m.currency])
// Tamara: a number plus a currency, e.g. { amount: 100, currency: 'SAR' }
export const toTamara = (m: Money) => ({ amount: m.minor / scale(m.currency), currency: m.currency })
// Checkout.com: the integer itself (10000 = SAR 100.00, 100000 = KWD 100.000)
export const toCheckoutCom = (m: Money) => m.minor
// Reading a provider's amount back into your model.
export function fromDecimal(value: string | number, currency: Currency): Money {
const minor = Math.round(Number(value) * scale(currency))
if (!Number.isSafeInteger(minor) || minor < 0) throw new Error('bad amount: ' + value)
return { minor, currency }
}
Write a unit test for every currency you support, including a three-decimal one, and assert that fromDecimal(toTabby(m), c) returns the original amount. Checkout.com also caps the amount at nine digits, which is worth a validation check before the request.
How do you map each provider's statuses to one state machine?
Into six states of your own, with amounts tracked separately. Refund status is best derived from captured and refunded totals rather than from a status name, because Tabby's payment stays CLOSED after a refund.
| Your state | Tabby | Tamara |
|---|---|---|
| pending | CREATED | new; approved (the adapter then calls Authorise Order) |
| authorized | AUTHORIZED (a partial capture keeps it here) | authorised |
| captured | CLOSED with captures | partially or fully captured; partially or fully refunded |
| canceled | CLOSED with no captures | canceled |
| failed | REJECTED | declined |
| expired | EXPIRED | expired (checkout not completed in 30 minutes, or not captured in 90 days) |
// checkout/tabby-status.ts
import type { PaymentState } from './types'
export function fromTabby(status: string, capturedMinor: number): PaymentState {
switch (status) {
case 'CREATED': return 'pending'
case 'AUTHORIZED': return 'authorized'
case 'CLOSED': return capturedMinor > 0 ? 'captured' : 'canceled'
case 'REJECTED': return 'failed'
case 'EXPIRED': return 'expired'
default: throw new Error('unknown Tabby status: ' + status) // fail loudly, never guess
}
}
The default branch matters. A new status added by a provider should page someone, not silently become "pending" while the customer's instalment plan is already running.
How should webhooks be handled when they arrive out of order or twice?
Verify, re-fetch, then apply forward only. Tabby says delivery order is not guaranteed and the same event may arrive twice; Checkout.com guarantees at least once delivery but not order. So a captured event can land before authorized, and a handler that trusts the payload will move the order backwards.
// checkout/webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { CheckoutProvider, PaymentState } from './types'
// Terminal states outrank earlier ones; a late 'authorized' never undoes 'captured'.
const RANK: Record<PaymentState, number> = {
pending: 0, authorized: 1, captured: 2, canceled: 2, failed: 2, expired: 2,
}
export async function handleWebhook(provider: CheckoutProvider, req: Request): Promise<Response> {
const rawBody = await req.text() // verify the exact bytes, before JSON.parse
const hit = await provider.verifyWebhook(req.headers, rawBody)
if (!hit) return new Response('invalid', { status: 401 })
// Ignore the payload's status; ask the provider for the current one.
const snap = await provider.fetchSnapshot(hit.providerRef)
await db.transaction(async (tx) => {
const row = await tx.payments.lockByRef(provider.id, snap.providerRef)
if (RANK[snap.state] < RANK[row.state]) return // stale: keep what we have
await tx.payments.update(row.id, {
state: snap.state,
capturedMinor: Math.max(row.capturedMinor, snap.capturedMinor),
refundedMinor: Math.max(row.refundedMinor, snap.refundedMinor),
})
})
return new Response('ok', { status: 200 })
}
// Checkout.com: HMAC of the raw body with your webhook key, hex-encoded in
// Cko-Signature. Confirm the hash algorithm for your account in its docs.
const CKO_ALGO = 'sha256'
export function verifyCkoSignature(rawBody: string, header: string | null, key: string): boolean {
if (!header) return false
const expected = createHmac(CKO_ALGO, key).update(rawBody).digest()
const given = Buffer.from(header, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
}
Here db stands for your data layer; the row lock makes two concurrent deliveries for one payment apply one after the other. Four more rules keep this safe in production:
- Answer quickly. Tabby times out a webhook after 1 minute. If re-fetching is slow, store the reference, return 200 and process it from a queue.
- Verify with what each provider documents. Checkout.com signs with an HMAC; Tamara attaches a
tamaraTokenJWT (HS256) that you check with your notification token (Tamara: webhook registration); Tabby lets you register a custom header to check. For any provider, re-fetching the status from its API means a forged webhook cannot change your data, only trigger a read. - Run a reconciliation job. Once a day, re-fetch every payment still pending or authorised. A dropped webhook then costs a delay, not a lost order.
- Capture on time. Tabby pays out only after a payment is closed, and Tamara auto-captures after 21 days. Capture on fulfilment, and alert on anything authorised for more than a day or two.
The same pattern, with Stripe's event model, is in webhook returned 200 but the subscription did not update.
How do refunds and cancellations work across BNPL and cards?
Through one refund ledger in your database, with each provider's operation chosen by the adapter. Record every refund request with its own idempotency key before calling the provider, and enforce that refunds never exceed what was captured.
| Situation | Tabby | Tamara | Card via Checkout.com |
|---|---|---|---|
| Customer cancels before capture | Close the payment without capturing | Cancel the order | Void the authorisation; for mada, which is captured in full, refund instead |
| Full refund after capture | Refund; status stays CLOSED | Simplified refund; becomes fully refunded | Refund against the payment ID |
| Partial refund (one item returned) | Partial refund | Partial refund; becomes partially refunded | Partial refund |
-- One row per refund attempt. The unique key makes a retried click harmless.
CREATE TABLE refunds (
id bigserial PRIMARY KEY,
payment_id bigint NOT NULL REFERENCES payments(id),
amount_minor bigint NOT NULL CHECK (amount_minor > 0),
idempotency_key text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'requested', -- requested | succeeded | failed
created_at timestamptz NOT NULL DEFAULT now()
);
Before inserting, sum the succeeded and requested refunds for the payment inside the same transaction and reject the request if the new total would exceed the captured amount. Then call the adapter, and let the webhook plus re-fetch mark it succeeded.
How do you test a multi-provider checkout before launch?
With the fake adapter for logic, each provider's sandbox for contracts, and replayed webhooks for the ugly cases. The cases that catch real bugs:
- A Tabby session returns
rejected, and the checkout offers card and Tamara instead. - A
capturedwebhook arrives beforeauthorized, and the order still ends captured. - The same webhook arrives twice, and the order is fulfilled once.
- A Tamara order sits approved, and the adapter authorises it.
- A mada payment on a split shipment is charged in full up front.
- Two refund clicks within a second create one refund.
- A KWD amount with three decimals round-trips exactly.
- The reconciliation job picks up a payment whose webhook was dropped.
The full method is in testing payments and webhooks end to end, and keeping these tests running on every release is covered in regression testing on every deploy.
Do you need anything from SAMA to add BNPL to a checkout?
That depends on your model, and it is a question for your adviser, not your developer. The Saudi Central Bank (SAMA) says its functions include the "issuance of rules, instructions, and licenses" and the "regulation and supervision of payment, settlement, and clearing systems" (SAMA functions). A merchant offering a licensed provider's BNPL at checkout is a different position from a marketplace that collects money on behalf of sellers. This is general information; confirm the current rule with your adviser.
The engineering answer that keeps options open: let the providers hold and move the money, keep your ledger as a record, and never build a balance that customers or sellers can top up and withdraw without advice. The wider payments picture is in the payments engineering guide.
Why RAITHub for this?
- Multi-gateway checkouts have shipped. TheSkinProof, the founder's own venture, runs bKash, Nagad, SSLCommerz and cash on delivery through one checkout, with 750+ automated tests across 217 API endpoints. PadhAI runs 9 payment gateways. PropDesk runs Stripe rent collection with 1,024 tests.
- The same failure modes. Out-of-order webhooks, duplicate deliveries, partial refunds and reconciliation are the problems those integrations already solve; Tabby, Tamara and Checkout.com add new adapters, not a new architecture.
- Honest scope. RAITHub has not shipped Tabby, Tamara, Checkout.com or mada integrations, so they are quoted as new work, built against the providers' sandboxes with the test cases above.
- Working hours that fit the Gulf. About 6 shared hours with Riyadh and 7 with Dubai, and a working week agreed with you, so a Sunday to Thursday week can be accommodated.
When you don't need us
- Your store runs on a hosted platform with official plugins for these providers. Install the plugins; a custom abstraction is only worth it when you own the checkout code.
- You only need one provider. One BNPL option next to one card gateway can be integrated directly; the interface above pays off from the third integration or the first re-platforming.
- You need a vendor who has already shipped these exact integrations and can show them in production.
- You need licensing or regulatory advice on holding customer funds. Ask a qualified adviser first.
For the backend work behind a checkout like this, see the API and backend development service; for the test suite around it, the QA and test automation service. When you are ready, book the free 15-minute technical audit and bring the providers you have signed with and how your orders are fulfilled.
Last reviewed: 30 September 2026. Provider documentation checked on 30 September 2026.
Frequently asked questions
How do I integrate Tabby and Tamara in the same checkout?
Put both behind one provider interface in your code, with an adapter for each. Store amounts as integer minor units, map each provider's statuses to your own states, and handle webhooks by re-fetching the payment status rather than trusting the payload.
What amount format do Tabby, Tamara and Checkout.com use?
Tabby's session API takes a decimal string, with up to 2 decimals for SAR and AED and 3 for KWD. Tamara takes a number with a currency. Checkout.com takes an integer in minor units, so 10000 is SAR 100.00. Convert from integers inside each adapter.
Why do Tabby and Checkout.com webhooks arrive out of order?
Both document that delivery order is not guaranteed, and Tabby notes that an event may arrive twice. Treat each webhook as a signal to fetch the current status, and only move a payment forward, never back.
Can I partially capture a mada payment?
Not through Checkout.com, according to its mada documentation: mada transactions must be captured in full, and authorisation-only requests are not supported. Charge mada in full at checkout and use partial refunds for returns.
What happens if I never capture a Tamara order?
Tamara's order status documentation says it auto-captures an order 21 days after authorisation if you have not captured it. Capture on fulfilment instead, so settlement matches what you shipped.
Has RAITHub integrated Tabby, Tamara or Checkout.com before?
No. RAITHub has shipped Stripe in PropDesk, bKash, Nagad and SSLCommerz in TheSkinProof, the founder's own venture, and 9 gateways in PadhAI. Tabby, Tamara and Checkout.com would be quoted as new work using the same patterns.
Related posts
Technical SEO Checklist for 2026: The Foundation That Lets You Rank
7 min readLocal and Geo SEO for Service Businesses: Rank Where Your Customers Are
7 min readSaaS Entitlements: Enforcing Plans, Limits and Add-ons in Code
14 min readReady to discuss your project?
Book a free 15-minute technical audit with our engineering team.