Search for "Tap Payments API integration" and you land on the vendor's developer portal, a handful of SDK readme files for Android and iOS, and a Postman collection with no explanation of the 3DS flow. What you will not find is a TypeScript guide that shows the exact sequence from token creation through webhook confirmation.
This tutorial is that guide. It covers the Charges v2 API end to end: tokenizing a card, creating a charge with 3DS enforcement, retrieving the result after the customer's redirect, verifying webhook signatures, and issuing refunds. Every code sample compiles under strict TypeScript and targets the Tap sandbox before touching live keys.
If your project uses Moyasar, Tabby or mada alongside Tap, see our Saudi payment gateway tutorial — it covers those gateways in depth and explains the halala-safe money handling pattern that applies across all of them.
What is Tap Payments
Tap Payments is a payment gateway licensed in Saudi Arabia, Kuwait, UAE, Bahrain and Egypt. It accepts mada, Visa, Mastercard, American Express, KNET (Kuwait), BENEFIT (Bahrain), Apple Pay, STC Pay and Tabby — all through a single API. That pan-MENA licence is its main differentiator from Moyasar, which is Saudi-only. If your product will charge customers in multiple Gulf states from the same backend, Tap is the default choice.
Tap charges are settled in SAR, KWD, AED, BHD or EGP — you specify the currency per charge, and Tap handles the FX on cross-border transactions.
Prerequisites
- Node.js 20+ and a TypeScript project
- A Tap Business account (sandbox mode). Register at tap.company, complete KYB for the relevant market, then navigate to goSell → Settings → API Credentials to collect your keys
- Two environment variables from the credentials page:
TAP_SECRET_KEY— yoursk_test_...key for server callsTAP_WEBHOOK_SECRET— the secret Tap uses to sign webhook payloads (set in goSell → Webhooks)
- The
cryptomodule (built into Node.js, no install needed)
# .env
TAP_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
TAP_WEBHOOK_SECRET=your_webhook_signing_secret
TAP_API_URL=https://api.tap.company/v2Never pass your secret key to the browser. Tap also issues a public key (pk_test_...) for client-side card SDK use. This tutorial covers only server-side integration.
Step 1: The TypeScript Client
Tap's v2 API is REST over HTTPS. Authentication is a standard Authorization: Bearer header with your secret key. Build a thin typed client that every step below imports:
// lib/tap/client.ts
const BASE_URL = process.env.TAP_API_URL ?? 'https://api.tap.company/v2';
const SECRET_KEY = process.env.TAP_SECRET_KEY!;
if (!SECRET_KEY) throw new Error('TAP_SECRET_KEY is not set');
async function tapFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Authorization': `Bearer ${SECRET_KEY}`,
'Content-Type': 'application/json',
...options.headers,
},
});
const body = await res.json() as T;
if (!res.ok) {
const err = body as { errors?: Array<{ code: string; description: string }> };
const msg = err.errors?.[0]?.description ?? `Tap API error ${res.status}`;
throw new Error(msg);
}
return body;
}
export { tapFetch };The error shape Tap returns is an errors array with code and description fields. Surfacing the first description gives actionable messages in your logs.
Step 2: Tokenize a Card
Tap requires card details to pass through a token before they reach a charge. In a browser-first flow, the customer's browser talks to Tap's card SDK, which returns a single-use token id (starting with tok_). Your server then creates the charge against that token — raw card numbers never touch your servers.
For server-to-server testing in the sandbox, you can create a token directly:
// lib/tap/tokens.ts
import { tapFetch } from './client';
export interface TapToken {
id: string; // tok_XXXX
object: 'token';
used: boolean;
card: {
first_six: string;
last_four: string;
brand: string; // "VISA" | "MASTERCARD" | "MADA" etc.
exp_month: number;
exp_year: number;
fingerprint: string;
};
}
export async function createToken(params: {
number: string;
exp_month: number;
exp_year: number;
cvc: string;
name: string;
}): Promise<TapToken> {
return tapFetch<TapToken>('/tokens', {
method: 'POST',
body: JSON.stringify({ card: params }),
});
}Sandbox test cards: use 4111111111111111 (Visa, triggers 3DS), 5123456789012346 (Mastercard), or the mada test number 5078036618359978. All accept any future expiry and CVC 100.
Tokens are single-use and expire after a few minutes. Never cache or reuse a token across requests.
Step 3: Create a Charge
A charge ties an amount and currency to a token (or a saved card source) and tells Tap where to redirect the customer after 3DS and where to POST the webhook:
// lib/tap/charges.ts
import { tapFetch } from './client';
export type ChargeStatus =
| 'INITIATED'
| 'AUTHORIZED'
| 'CAPTURED'
| 'FAILED'
| 'DECLINED'
| 'CANCELLED'
| 'REFUNDED';
export interface TapCharge {
id: string; // chg_XXXX
status: ChargeStatus;
amount: number;
currency: string;
transaction: {
url?: string; // present when 3DS redirect is required
authorization_id?: string;
};
reference: {
transaction?: string;
order?: string;
};
response: {
code: string;
message: string;
};
}
export interface CreateChargeParams {
amount: number;
currency: 'SAR' | 'KWD' | 'AED' | 'BHD' | 'EGP';
tokenId: string;
orderId: string;
description: string;
customer: {
firstName: string;
lastName: string;
email: string;
phone: { countryCode: string; number: string };
};
redirectUrl: string;
webhookUrl: string;
}
export async function createCharge(p: CreateChargeParams): Promise<TapCharge> {
return tapFetch<TapCharge>('/charges', {
method: 'POST',
body: JSON.stringify({
amount: p.amount,
currency: p.currency,
customer_initiated: true,
threeDSecure: true,
save_card: false,
description: p.description,
statement_descriptor: 'MYSTORE',
reference: { transaction: `txn_${p.orderId}`, order: p.orderId },
receipt: { email: true, sms: true },
customer: {
first_name: p.customer.firstName,
last_name: p.customer.lastName,
email: p.customer.email,
phone: {
country_code: p.customer.phone.countryCode,
number: p.customer.phone.number,
},
},
source: { id: p.tokenId },
post: { url: p.webhookUrl },
redirect: { url: p.redirectUrl },
}),
});
}threeDSecure: true is mandatory for mada. Saudi regulations require 3DS authentication on all mada transactions. Tap enforces this automatically when it detects a mada BIN — your code never needs to branch on card brand — but setting the flag explicitly ensures 3DS on all cards, which protects you on chargebacks across every card scheme.
Amount precision: Tap expects the full decimal amount in major units. SAR 149.50 is passed as 149.5, not as 14950 halalas. This is different from Moyasar, which expects amounts in halalas (minor units). Keep a comment near every call site documenting which unit your gateway uses.
Step 4: Handle the 3DS Redirect
When the card requires authentication, charge.transaction.url is populated. Redirect the customer there. When they complete (or fail) 3DS, Tap redirects them to your redirectUrl appending tap_id=chg_XXXX as a query parameter.
Your callback endpoint:
// app/api/payments/tap-callback/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';
import { tapFetch } from '@/lib/tap/client';
import type { TapCharge } from '@/lib/tap/charges';
export async function GET(req: NextRequest) {
const tapId = req.nextUrl.searchParams.get('tap_id');
if (!tapId) {
return NextResponse.redirect('/checkout/error?reason=missing_tap_id');
}
const charge = await tapFetch<TapCharge>(`/charges/${tapId}`);
if (charge.status === 'CAPTURED') {
// Mark the order paid in your database here
return NextResponse.redirect(`/orders/${charge.reference.order}/success`);
}
if (charge.status === 'FAILED' || charge.status === 'DECLINED') {
return NextResponse.redirect(`/checkout/error?reason=${charge.response.code}`);
}
// INITIATED or AUTHORIZED: payment not yet confirmed
return NextResponse.redirect('/checkout/pending');
}Never trust the redirect alone. The customer can manipulate query parameters. Retrieve the charge from the API (GET /charges/:id) and check status === "CAPTURED" before marking anything paid.
A charge that returns AUTHORIZED is reserved but not yet settled. You must capture it separately with POST /charges/{id}/capture — or configure auto-capture in your Tap dashboard so you never need to call it manually. For most e-commerce flows, auto-capture is the correct choice.
Step 5: Verify Webhooks
Tap POSTs a webhook to your post.url for every terminal charge state change. Each request includes an X-Signature header: an HMAC-SHA256 hex digest of the raw request body, keyed with your webhook secret.
// lib/tap/webhooks.ts
import { createHmac, timingSafeEqual } from 'crypto';
const WEBHOOK_SECRET = process.env.TAP_WEBHOOK_SECRET!;
export function verifyTapSignature(rawBody: string, signature: string): boolean {
const expected = createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}// app/api/webhooks/tap/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyTapSignature } from '@/lib/tap/webhooks';
import type { TapCharge } from '@/lib/tap/charges';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-signature') ?? '';
if (!verifyTapSignature(rawBody, signature)) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}
const event = JSON.parse(rawBody) as TapCharge;
if (event.status === 'CAPTURED') {
// fulfill order: event.reference.order is your orderId
} else if (event.status === 'FAILED' || event.status === 'DECLINED') {
// release reserved inventory
}
return NextResponse.json({ received: true });
}Use timingSafeEqual instead of ===. String comparison short-circuits on the first mismatched character, leaking timing information that an attacker can exploit to forge signatures. The timing-safe comparison runs in constant time regardless of where the mismatch occurs.
Webhooks and redirects can arrive in any order. A slow mobile network can delay the redirect while the webhook arrives first, or the webhook can be delayed by a Tap retry while your callback endpoint has already confirmed the charge via the retrieve call. Your order state machine must be idempotent: if you receive two CAPTURED events for the same charge id, the second must be a no-op.
Step 6: Refund a Charge
Tap refunds are separate objects tied to a charge id:
// lib/tap/refunds.ts
import { tapFetch } from './client';
export interface TapRefund {
id: string; // ref_XXXX
status: 'INITIATED' | 'CAPTURED' | 'FAILED';
amount: number;
currency: string;
charge_id: string;
reason: string;
}
export async function refundCharge(params: {
chargeId: string;
amount: number;
currency: 'SAR' | 'KWD' | 'AED' | 'BHD' | 'EGP';
reason?: string;
}): Promise<TapRefund> {
return tapFetch<TapRefund>('/refunds', {
method: 'POST',
body: JSON.stringify({
charge_id: params.chargeId,
amount: params.amount,
currency: params.currency,
reason: params.reason ?? 'Customer request',
}),
});
}Tap supports partial refunds: pass any amount up to the original charge amount. Multiple partial refunds against the same charge are allowed until the total refunded equals the captured amount.
Refund settlement time varies by card scheme: mada refunds typically post within 3–5 business days; international Visa/Mastercard can take up to 10.
Step 7: End-to-End Test Checklist
Run through these scenarios in the sandbox before going live:
- Token creation succeeds with the Visa test card
- Charge with
threeDSecure: truereturnsINITIATEDstatus and a non-nulltransaction.url - Completing 3DS in the sandbox test flow redirects to your callback with
tap_id - Callback retrieves the charge and reads
CAPTURED - Webhook fires for the same charge; signature verification passes
- Idempotency: triggering the webhook handler twice for the same charge id is a no-op
- Failing 3DS (sandbox decline) returns
FAILEDin both the callback and webhook - Partial refund succeeds and the refund
statusresolves toCAPTURED - mada test card flow:
5078036618359978— confirmcharge.source.card.brandis"MADA"
Production Checklist
Before switching to live keys:
- Replace
sk_test_...withsk_live_...in your secrets manager — never in code - Set the webhook secret in Tap's dashboard for the production endpoint
- Verify the TLS certificate on your webhook endpoint (Tap drops requests to non-HTTPS URLs)
- Confirm your
statement_descriptorvalue (displayed on the cardholder's bank statement — keep it under 22 characters, matching your trade name) - Enable idempotent order updates in your database (a unique constraint on
charge_idprevents double-fulfillment from webhook retries) - Set up alerting for
DECLINEDandFAILEDevents — a spike often signals a misconfigured 3DS parameter or a region-specific card restriction
Next Steps
This tutorial covered the core charge lifecycle. Tap's v2 API has two additional surfaces worth exploring once the basics are solid:
Saved cards. Set save_card: true on the charge and Tap returns a card.id you can store against a customer. Subsequent charges reference that id as the source, skipping card entry for returning customers.
Apple Pay and STC Pay. Both are available as source types through the same Charges endpoint. Apple Pay requires a web domain verification file served at /.well-known/apple-developer-merchantid-domain-association, which Tap's dashboard will generate for you.
For a broader look at the Saudi payment ecosystem — including mada BIN handling, Moyasar's halala-unit convention, and Tabby's split-payment authorize-capture flow — see the Saudi payment gateway integration tutorial.
Building a product in Saudi Arabia or across the Gulf? We integrate Tap Payments, Moyasar, ZATCA e-invoicing and government APIs for engineering teams who want the implementation done correctly the first time. Talk to the Noqta team.