Search for how to integrate a Saudi payment gateway and you will find the vendor's marketing site, a dozen "best payment gateways in KSA" comparison posts, and a freelance marketplace request from someone offering money to have it done for them. What you will not find is a guide that explains the parts that actually break.
This tutorial is that guide. It is not a tour of a dashboard. It is the code that sits between your order table and the money, written the way it has to be written when the customer is paying with a mada card in Riyadh.
Three things make a Saudi checkout different from copying a Stripe quickstart:
- The two main gateways disagree about what a number is. Moyasar wants integer halalas. Tabby wants a decimal string. Send one where the other belongs and you have charged a customer one hundred times too much, or too little, with no error anywhere.
- 3-D Secure is not an optimisation you can defer. On mada it is the path, not the exception, so the redirect-and-return flow is your main flow rather than an edge case.
- Buy-now-pay-later underwrites the shopper, not the card. Tabby can decline a customer your card gateway would happily have charged, before any payment page is shown. That is a normal outcome your checkout has to handle gracefully.
What You'll Build
A payment core with four modules:
money.ts— a brandedHalalastype that makes the unit mismatch a compile error instead of a refundmoyasar.ts— card payments with 3-D Secure, plus tamper-proof verification of the browser returntabby.ts— BNPL checkout, rejection handling, and capture on fulfilmentwebhook.ts— constant-time signature checking and idempotent event processing
Everything below type-checks under strict with noUncheckedIndexedAccess and exactOptionalPropertyTypes, and is covered by a test suite that passes.
Prerequisites
- Node.js 20 or newer, and TypeScript 5.5+
- A Moyasar account with test keys (
pk_test_...andsk_test_...) - A Tabby merchant account with a test secret key and merchant code
- Familiarity with async/await and HTTP APIs
- A commercial registration (CR) is required before either gateway will issue live keys — start that paperwork early, since it gates go-live, not development
Set up the project:
mkdir saudi-payments && cd saudi-payments
npm init -y
npm install -D typescript vitest @types/node
npx tsc --initThen turn on the strictness that will do the work for you:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}Step 1: Make the Unit Mismatch Impossible
Here is the bug that this entire step exists to prevent.
Moyasar's API takes amount as an integer in the smallest currency unit. One Saudi riyal is 100 halalas, so SAR 100.00 is 10000. Tabby's API takes amount as a decimal string in major units, so the same SAR 100.00 is "100.00".
Both fields are called amount. Both gateways accept what the other one sends without complaint — 10000 is a perfectly valid amount to Tabby, it just means ten thousand riyals. Nothing throws. You find out from the customer.
The fix is to never let a bare number reach a gateway. Use a branded type:
// money.ts
/** Branded integer count of halalas. 1 SAR = 100 halalas. */
export type Halalas = number & { readonly __brand: 'Halalas' };
export class MoneyError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoneyError';
}
}
/** Build Halalas from an already-minor-unit integer. */
export function halalas(value: number): Halalas {
if (!Number.isInteger(value)) {
throw new MoneyError(`Halalas must be an integer, received ${value}`);
}
if (value < 0) {
throw new MoneyError(`Halalas must not be negative, received ${value}`);
}
if (!Number.isSafeInteger(value)) {
throw new MoneyError(`Halalas exceeds safe integer range: ${value}`);
}
return value as Halalas;
}The brand is a compile-time fiction — at runtime it is still a number, with zero overhead. But a function that demands Halalas cannot be handed the result of price * quantity, and that is the whole point.
Now parse prices. Note that this takes a string, deliberately:
/**
* Parse a decimal SAR string ("100.00", "9.5", "1,250.75") into Halalas.
*
* Deliberately string-first: `Math.round(19.99 * 100)` is 1999 today and a
* support ticket the day a price lands on a value that floats badly.
*/
export function sarToHalalas(input: string): Halalas {
const raw = input.trim().replace(/,/g, '');
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(raw);
if (!match) {
throw new MoneyError(
`Invalid SAR amount "${input}" — expected digits with at most 2 decimals`,
);
}
const major = match[1] ?? '0';
const minor = (match[2] ?? '').padEnd(2, '0');
return halalas(Number(major) * 100 + Number(minor));
}
/** Render Halalas as the 2-decimal string Tabby expects. */
export function halalasToSar(amount: Halalas): string {
const major = Math.trunc(amount / 100);
const minor = amount % 100;
return `${major}.${String(minor).padStart(2, '0')}`;
}
/** Sum line items without ever leaving integer arithmetic. */
export function sumHalalas(amounts: readonly Halalas[]): Halalas {
return halalas(amounts.reduce<number>((total, value) => total + value, 0));
}Why string parsing and not
Math.round(price * 100)? Because19.99 * 100evaluates to1998.9999999999998in IEEE 754 floating point.Math.roundrescues that particular case, but the pattern is a habit that eventually meets a value it does not rescue, and by then the rounding error is spread across a settlement report. Parse the decimal representation directly and the failure mode disappears rather than becoming rare.
The regex also rejects three-decimal input instead of silently truncating it. If a supplier feed hands you "1.005", you want an exception at import time, not a half-halala that quietly rounds in whichever direction your database prefers.
Step 2: VAT and Installment Splits
Two pieces of arithmetic that every Saudi checkout needs.
Saudi VAT is 15%, and consumer prices are displayed VAT-inclusive. So the tax is extracted from the price, not added to it — and the split has to reconcile to the halala or your ZATCA e-invoice will not tie back to what you actually charged:
export function extractVat(grossInclusive: Halalas, ratePercent = 15): Halalas {
const net = Math.round((grossInclusive * 100) / (100 + ratePercent));
return halalas(grossInclusive - net);
}Computing the net first and subtracting guarantees net + vat === gross exactly. Computing the VAT first and subtracting that does not, because two independent roundings can each drift by half a halala in the same direction.
The installment split has the same discipline. Tabby's standard product is four payments, and SAR 100.01 does not divide by four:
/**
* Split a total across n installments (Tabby is 4) so the parts sum exactly to
* the total. The remainder goes to the FIRST installment, which is the one the
* customer pays at checkout.
*/
export function splitInstallments(
total: Halalas,
parts: number,
): readonly Halalas[] {
if (!Number.isInteger(parts) || parts < 1) {
throw new MoneyError(`Installment count must be a positive integer`);
}
const base = Math.floor(total / parts);
const remainder = total - base * parts;
return Array.from({ length: parts }, (_unused, index) =>
halalas(index === 0 ? base + remainder : base),
);
}You only need this for display — Tabby computes its own schedule — but customers do compare your checkout summary against their Tabby app, and a one-halala disagreement generates support tickets out of all proportion to its size.
Step 3: Card Payments with Mandatory 3-D Secure
mada is Saudi Arabia's national payment network, and most Saudi debit cards run on it. Practically speaking, that means strong customer authentication is part of the normal path: the shopper is sent to their bank, approves with an OTP or their banking app, and comes back.
Your integration therefore cannot treat 3-D Secure as a branch that occasionally fires. The redirect is the flow.
// moyasar.ts
import { halalasToSar, type Halalas } from './money.js';
const MOYASAR_API = 'https://api.moyasar.com/v1';
export type MoyasarStatus =
| 'initiated'
| 'paid'
| 'authorized'
| 'captured'
| 'refunded'
| 'failed'
| 'voided';
export interface MoyasarPayment {
readonly id: string;
readonly status: MoyasarStatus;
/** Integer halalas, as returned by Moyasar. */
readonly amount: number;
readonly currency: string;
readonly metadata?: Record<string, string>;
}
export class GatewayError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'GatewayError';
}
}
/** Basic auth: the key is the username, password is empty. */
function authHeader(key: string): string {
return `Basic ${Buffer.from(`${key}:`).toString('base64')}`;
}And the payment creation itself:
export interface CreatePaymentInput {
readonly amount: Halalas;
readonly orderId: string;
readonly description: string;
readonly callbackUrl: string;
/** Moyasar-hosted token from the browser form. Raw PAN never touches us. */
readonly cardToken: string;
}
export async function createPayment(
secretKey: string,
input: CreatePaymentInput,
): Promise<MoyasarPayment> {
const response = await fetch(`${MOYASAR_API}/payments`, {
method: 'POST',
headers: {
Authorization: authHeader(secretKey),
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: input.amount,
currency: 'SAR',
description: input.description,
callback_url: input.callbackUrl,
source: {
type: 'token',
token: input.cardToken,
// 3DS defaults to true. Leaving it on is not optional in practice:
// mada mandates SCA, and turning it off shifts fraud liability to you.
'3ds': true,
},
metadata: { order_id: input.orderId },
}),
});
if (!response.ok) {
throw new GatewayError(
`Moyasar create payment failed: ${await response.text()}`,
response.status,
);
}
return (await response.json()) as MoyasarPayment;
}Notice amount: input.amount goes straight through with no conversion. That is safe precisely because the type system already proved it is halalas.
On disabling 3DS: the API does accept
"3ds": false, but only for accounts explicitly enabled for mail-order/telephone-order processing, and it moves fraud liability from the issuer to you. For an ordinary web checkout, treat the flag as if it were not there.
Two other things worth doing right at this point. Use a card token produced by Moyasar's hosted form rather than posting a raw card number through your own server — the code above is written for tokens, and it is what keeps card data out of your PCI scope. And put order_id in metadata, because Step 4 depends on it.
Step 4: Never Trust the Callback
This is the security core of the integration, and it is the step most tutorials skip.
When the customer finishes authenticating, Moyasar redirects the browser to your callback_url with query parameters appended: id, status and message. Those parameters travel through the address bar. The customer can read them. The customer can also edit them.
So this handler, which looks entirely reasonable, is a way to give your inventory away:
// DO NOT DO THIS
app.get('/callback', async (req, res) => {
if (req.query.status === 'paid') {
await markOrderPaid(req.query.id); // trusting the address bar
}
});Anyone can append ?status=paid to that URL. Instead, re-fetch the payment from the API using your secret key, and check every field that matters:
export async function fetchPayment(
secretKey: string,
paymentId: string,
): Promise<MoyasarPayment> {
const response = await fetch(`${MOYASAR_API}/payments/${paymentId}`, {
headers: { Authorization: authHeader(secretKey) },
});
if (!response.ok) {
throw new GatewayError(
`Moyasar fetch payment failed: ${await response.text()}`,
response.status,
);
}
return (await response.json()) as MoyasarPayment;
}
export type VerdictReason =
| 'ok'
| 'not_paid'
| 'amount_mismatch'
| 'currency_mismatch'
| 'order_mismatch';
export interface Verdict {
readonly settled: boolean;
readonly reason: VerdictReason;
}
export function verifyPayment(
payment: MoyasarPayment,
expected: { readonly amount: Halalas; readonly orderId: string },
): Verdict {
if (payment.status !== 'paid' && payment.status !== 'captured') {
return { settled: false, reason: 'not_paid' };
}
if (payment.currency !== 'SAR') {
return { settled: false, reason: 'currency_mismatch' };
}
if (payment.amount !== expected.amount) {
return { settled: false, reason: 'amount_mismatch' };
}
if (payment.metadata?.['order_id'] !== expected.orderId) {
return { settled: false, reason: 'order_mismatch' };
}
return { settled: true, reason: 'ok' };
}All four checks earn their place:
- Status is the obvious one, and the only one most implementations do.
- Amount blocks the classic attack: put one cheap item in a basket, pay SAR 1.00, then reuse that payment id against an expensive order.
- Currency blocks settlement in something that is not riyals.
- Order id blocks replaying one genuine payment against several orders — without this check, a single legitimate SAR 500 payment can mark five different SAR 500 orders as paid.
The wiring then looks like this:
const payment = await fetchPayment(process.env.MOYASAR_SECRET_KEY!, paymentId);
const order = await loadOrder(orderId);
const verdict = verifyPayment(payment, {
amount: order.totalHalalas,
orderId: order.id,
});
if (!verdict.settled) {
logger.warn({ paymentId, reason: verdict.reason }, 'payment rejected');
return res.redirect('/checkout/failed');
}
await markOrderPaid(order.id, payment.id);Log verdict.reason. An amount_mismatch in production is either a bug in your basket totals or somebody probing you, and you want to know which.
Step 5: BNPL Is a Different Animal
Tabby and Tamara dominate Saudi buy-now-pay-later, and integrating one is not "adding another card gateway." The difference that changes your code is this: Tabby underwrites the shopper at checkout-creation time, before any payment page appears.
You call POST /api/v2/checkout and the response tells you whether this customer may use BNPL for this basket at all. A rejected status is not an error and not retryable — it is an underwriting decision. Your job is to fall back to card without making the customer feel refused.
// tabby.ts
import { halalasToSar, type Halalas } from './money.js';
import { GatewayError } from './moyasar.js';
const TABBY_API = 'https://api.tabby.ai/api/v2';
export type TabbySessionStatus = 'created' | 'rejected' | 'expired';
export type TabbyPaymentStatus =
| 'NEW'
| 'AUTHORIZED'
| 'CLOSED'
| 'REJECTED'
| 'EXPIRED';
export interface TabbyCheckoutResponse {
readonly status: TabbySessionStatus;
readonly payment: { readonly id: string };
readonly configuration?: {
readonly available_products?: {
readonly installments?: readonly { readonly web_url: string }[];
};
};
}
export type CheckoutOutcome =
| { readonly kind: 'redirect'; readonly url: string; readonly paymentId: string }
| { readonly kind: 'rejected'; readonly paymentId: string };The request, with the conversion isolated to exactly one call:
export async function createCheckout(
secretKey: string,
merchantCode: string,
input: TabbyCheckoutInput,
): Promise<CheckoutOutcome> {
const response = await fetch(`${TABBY_API}/checkout`, {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
payment: {
// Tabby wants a DECIMAL STRING, where Moyasar wanted an integer.
// `halalasToSar` is the only place that conversion is allowed to happen.
amount: halalasToSar(input.amount),
currency: 'SAR',
buyer: input.buyer,
order: { reference_id: input.orderId },
},
lang: input.lang,
merchant_code: merchantCode,
merchant_urls: {
success: input.successUrl,
cancel: input.cancelUrl,
failure: input.failureUrl,
},
}),
});
if (!response.ok) {
throw new GatewayError(
`Tabby checkout failed: ${await response.text()}`,
response.status,
);
}
const session = (await response.json()) as TabbyCheckoutResponse;
const url =
session.configuration?.available_products?.installments?.[0]?.web_url;
if (session.status !== 'created' || url === undefined) {
return { kind: 'rejected', paymentId: session.payment.id };
}
return { kind: 'redirect', url, paymentId: session.payment.id };
}The discriminated union is doing real work. There is no way to read .url without first narrowing on kind, so the rejection path cannot be forgotten — and with noUncheckedIndexedAccess on, installments?.[0] is undefined-typed, which forces you to handle the empty-array case that a rejected session actually returns.
Pass lang honestly. If your storefront is Arabic, send "ar" so the Tabby page matches; bouncing an Arabic shopper into an English payment flow is a measurable drop-off.
Step 6: Capture on Fulfilment, Not on Checkout
An authorized Tabby payment is a promise, not money. It becomes money when you capture it, and the right moment to capture is when the goods ship.
Capture at checkout and every routine cancellation becomes a refund — money that left the customer's account and has to be sent back, with the customer chasing you in the meantime.
export async function capturePayment(
secretKey: string,
paymentId: string,
amount: Halalas,
idempotencyKey: string,
): Promise<void> {
const response = await fetch(`${TABBY_API}/payments/${paymentId}/captures`, {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: halalasToSar(amount),
reference_id: idempotencyKey,
}),
});
if (!response.ok) {
throw new GatewayError(
`Tabby capture failed: ${await response.text()}`,
response.status,
);
}
}
/** Only these two statuses mean the order may be released. */
export function isSettled(status: TabbyPaymentStatus): boolean {
return status === 'AUTHORIZED' || status === 'CLOSED';
}reference_id is the idempotency key, not a description. Derive it from something stable — the shipment id works well — so that a retried capture after a network timeout does not take the money twice.
Partial capture is supported and is how you handle a partially-shipped order: capture what shipped, and the payment stays authorized for the remainder.
Step 7: Webhooks — Signature and Idempotency
Redirects are best-effort. The customer closes the tab, the phone loses signal on the way back from the banking app, the browser restores a session from cache. Webhooks are the channel that eventually tells you the truth, which means your handler has to be safe to run more than once.
Gateways differ in how they authenticate their webhooks — some send an HMAC signature header, some include a shared secret token in the payload. Check each gateway's dashboard for which. Whichever it is, two rules hold: compare in constant time, and deduplicate. The module below implements the HMAC-header variant; if yours uses a shared token, swap the body of verifySignature for a constant-time compare of that token and keep everything else exactly as it is.
// webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySignature(
rawBody: string,
receivedSignature: string,
secret: string,
): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = receivedSignature.trim().toLowerCase();
// timingSafeEqual throws on length mismatch, so guard first.
if (received.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}Sign the raw body. Verify against the exact bytes you received, before any JSON parsing. If you parse and re-serialise, key order and whitespace change, the HMAC no longer matches, and you will spend an afternoon convinced the gateway is broken. In Express, that means
express.raw()on the webhook route specifically, not the globalexpress.json().
Now idempotency:
export interface EventStore {
/** Returns true if this is the first time we have seen `eventId`. */
claim(eventId: string): Promise<boolean>;
}
export class InMemoryEventStore implements EventStore {
private readonly seen = new Set<string>();
async claim(eventId: string): Promise<boolean> {
if (this.seen.has(eventId)) return false;
this.seen.add(eventId);
return true;
}
}
export type WebhookResult = 'processed' | 'duplicate' | 'invalid_signature';
export async function handleWebhook(
rawBody: string,
signature: string,
secret: string,
store: EventStore,
process: (event: unknown) => Promise<void>,
): Promise<WebhookResult> {
if (!verifySignature(rawBody, signature, secret)) {
return 'invalid_signature';
}
const event = JSON.parse(rawBody) as { id?: string };
const eventId = event.id;
if (typeof eventId !== 'string' || eventId.length === 0) {
throw new Error('Webhook payload has no event id — cannot deduplicate');
}
if (!(await store.claim(eventId))) {
// Already handled. Return 200 so the gateway stops retrying.
return 'duplicate';
}
await process(event);
return 'processed';
}The in-memory store is for tests. In production, claim is an INSERT into a processed_events table with a UNIQUE constraint on the event id, and it must run in the same database transaction as the order update. Split them and a crash between the two puts you right back where you started: either an event marked processed that never was, or an order updated twice.
Return 200 for duplicates. A non-2xx tells the gateway to retry, and retrying a duplicate forever is a self-inflicted denial of service.
Testing Your Implementation
Every claim in this tutorial is covered by tests. The interesting ones:
describe('SAR money conversion', () => {
it('avoids the float trap that Math.round(x * 100) walks into', () => {
// The classic bug: 19.99 * 100 === 1998.9999999999998
expect(19.99 * 100).not.toBe(1999);
expect(sarToHalalas('19.99')).toBe(1999);
});
it('rejects more than two decimals rather than silently truncating', () => {
expect(() => sarToHalalas('1.005')).toThrow(MoneyError);
});
});
describe('Saudi VAT extraction', () => {
it('extracts 15% from a VAT-inclusive price', () => {
// SAR 115.00 inclusive => SAR 100.00 net + SAR 15.00 VAT
expect(extractVat(sarToHalalas('115.00'))).toBe(1500);
});
it('produces a net that grosses back up to the original price', () => {
// The real risk: net and vat each round, and the invoice no longer ties
// back to what was charged. Re-apply the rate to the net and check.
for (const price of ['9.99', '19.99', '33.33', '250.75', '1.01']) {
const gross = sarToHalalas(price);
const net = gross - extractVat(gross);
expect(Math.round(net * 1.15)).toBe(gross);
}
});
});
describe('Moyasar callback verification', () => {
const expected = { amount: sarToHalalas('100.00'), orderId: 'ORD-1' };
it('rejects a tampered amount', () => {
// The attack: pay SAR 1.00, then edit the redirect URL.
expect(verifyPayment(payment({ amount: 100 }), expected)).toEqual({
settled: false,
reason: 'amount_mismatch',
});
});
it('rejects a payment belonging to another order', () => {
expect(
verifyPayment(payment({ metadata: { order_id: 'ORD-999' } }), expected),
).toEqual({ settled: false, reason: 'order_mismatch' });
});
});
describe('webhook handling', () => {
it('processes an event once and ignores the retry', async () => {
const store = new InMemoryEventStore();
let calls = 0;
const run = () =>
handleWebhook(body, sign(body), secret, store, async () => {
calls += 1;
});
expect(await run()).toBe('processed');
expect(await run()).toBe('duplicate');
expect(calls).toBe(1);
});
});Run them with npx vitest run, and type-check with npx tsc --noEmit.
Beyond unit tests, exercise the real sandboxes before you go live. Both gateways publish test cards and test customer identities that deterministically produce a rejection, and the rejection paths are the ones your users will hit at three in the morning. Specifically, walk through: a 3-D Secure challenge that the customer abandons, a Tabby session that comes back rejected, a webhook delivered twice, and a capture retried after a timeout.
Troubleshooting
Amounts are off by exactly 100×. You sent halalas to Tabby or a decimal string to Moyasar. If you adopted the Halalas type, this becomes a compile error; if it reached production, it means somewhere a raw number was cast.
Signature verification always fails. You are hashing the parsed-and-re-serialised body. Capture the raw bytes at the middleware layer, before JSON parsing.
Payments stay initiated forever. The customer never completed the 3-D Secure challenge. This is normal and common on mobile — treat it as an abandoned checkout, not a failure, and make sure your webhook (not the redirect) is what eventually closes the order out.
Tabby returns rejected for every test order. Use the test buyer identities from Tabby's documentation; arbitrary phone numbers and emails will be declined by the pre-scoring model by design.
Orders occasionally get marked paid twice. Your idempotency claim and your order update are in different transactions.
Live keys are refused. Both gateways require completed merchant onboarding tied to a valid commercial registration before they will activate live mode. Test keys work immediately; live keys do not.
Next Steps
- Wire the settlement side by reconciling gateway payouts against your order table daily — the gateway is the source of truth for what was actually deposited, and it will differ from what you charged by fees and timing.
- Issue the corresponding tax invoice: see our ZATCA Phase 2 e-invoicing integration in TypeScript for the clearance and reporting side, which consumes exactly the VAT split computed in Step 2.
- If your checkout requires verified identity — regulated goods, high-value orders, B2B accounts — add national sign-in with our Nafath OAuth2/OIDC integration guide.
- For the wider commercial picture on selling into Saudi Arabia, read Salla API and the middleware pattern.
Conclusion
The hard parts of a Saudi checkout are not the HTTP calls. They are the three places where a reasonable-looking implementation quietly loses money: a unit mismatch that no gateway will flag, a redirect the customer can edit, and a webhook that runs twice.
The defences are all cheap. A branded Halalas type turns the first into a compile error. Re-fetching and checking four fields turns the second into a logged warning. A UNIQUE constraint in the same transaction turns the third into a no-op. None of these are more than a few dozen lines, and all three are much harder to retrofit once you have live orders.
Build them on day one.
Need a Saudi payments integration delivered rather than debugged? We build and operate mada, BNPL and ZATCA-compliant checkouts for Saudi and Gulf merchants. Talk to us.