Search docs

Jump between documentation pages.

Accept payments with Tap Payments in DaloyJS

Tap Payments is the default acquirer for the GCC and wider MENA region — it's how you accept KNET (Kuwait), Mada (Saudi), Benefit / BenefitPay (Bahrain), STC Pay, plus cards, Apple Pay, Google Pay, and BNPL methods like Tabby and Tamara. There's no first-party Node SDK; you integrate against the REST API with fetch.

What you should know up front

  • Bearer auth, secret key in the backend only. Authorization: Bearer sk_test_... or sk_live_.... Public keys (pk_*) are for the frontend SDKs; never send a secret key from the browser.
  • It's a redirect flow. You create a charge, Tap returns transaction.url, you redirect the customer. They come back via your redirect.url with ?tap_id=chg_xxx— that's a UX hint, not proof of payment.
  • Webhooks come with a hashstring. Tap sends an HMAC-SHA256 over a specific concatenation of fields, base64-encoded, in the hashstring header. Verify it on every request; never trust the body alone.
  • Amounts are decimals. Tap takes { amount: 10, currency: "KWD" }as a number — but KWD has 3 decimals (fils), SAR/AED/QAR/BHD have 2/3, USD 2. Use the right precision per currency or you'll under/overcharge.
  • Always re-fetch the charge. On both webhook and redirect return, GET /v2/charges/{id} before marking anything paid. The status you want is CAPTURED (or AUTHORIZED for the auth-only flow).

1. Provision

  1. Create an account at tap.company and sign in to the dashboard.
  2. Accounts → Operators → MERCHANT to grab your Merchant ID, plus Test/Live Secret Keys (sk_*) and Public Keys (pk_*).
  3. Enable the payment methods you need (KNET, Mada, Benefit, etc.) — some require contacting Tap support for activation.
  4. Configure a webhook URL on your account so Tap can POST events to your DaloyJS server.

2. Environment variables

ts
# .env
TAP_SECRET_KEY=sk_test_replace_me        # sk_live_... in production
TAP_PUBLIC_KEY=pk_test_replace_me        # ships to the browser SDKs
TAP_MERCHANT_ID=merchant_xxx
APP_URL=https://your-app.example.com

3. Plugin (no SDK — fetch-based)

ts
// src/plugins/tap.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import type { App } from "@daloyjs/core";

const BASE = "https://api.tap.company/v2";

type Money = { amount: number; currency: string };

type ChargeStatus =
  | "INITIATED" | "ABANDONED" | "CANCELLED" | "FAILED" | "DECLINED"
  | "RESTRICTED" | "CAPTURED" | "AUTHORIZED" | "VOID" | "TIMEDOUT" | "UNKNOWN";

interface TapCharge {
  id: string;
  status: ChargeStatus;
  amount: number;
  currency: string;
  reference?: { order?: string; transaction?: string };
  transaction?: { url?: string };
  customer?: { id?: string; first_name?: string; email?: string };
  source?: { id: string };
  metadata?: Record<string, string>;
}

async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.TAP_SECRET_KEY}`,
      "Content-Type": "application/json",
      ...(init.headers as Record<string, string> | undefined),
    },
  });
  const text = await res.text();
  const body = text ? JSON.parse(text) : null;
  if (!res.ok) {
    const err = new Error(`tap ${res.status}: ${body?.errors?.[0]?.description ?? res.statusText}`);
    Object.assign(err, { status: res.status, body });
    throw err;
  }
  return body as T;
}

// Field order matters — keep this aligned with the docs page:
// https://developers.tap.company/docs/webhook#validate-the-webhook-hashstring
function buildHashString(c: TapCharge): string {
  return [
    `x_id${c.id}`,
    `x_amount${c.amount.toFixed(3)}`,           // 3 decimals; trim later if needed for your currency
    `x_currency${c.currency}`,
    `x_gateway_reference${c.reference?.transaction ?? ""}`,
    `x_payment_reference${c.reference?.order ?? ""}`,
    `x_status${c.status}`,
    `x_created${(c as unknown as { transaction?: { created?: string } }).transaction?.created ?? ""}`,
  ].join("");
}

export interface TapClient {
  createCharge(input: {
    amount: number;
    currency: string;                                 // "KWD" | "SAR" | "AED" | ...
    description: string;
    orderId: string;
    customer: { first_name: string; email?: string; phone?: { country_code: string; number: string } };
    source: { id: string };                           // "src_all" (hosted), "src_kw.knet", "src_sa.mada", "src_card", ...
    metadata?: Record<string, string>;
  }): Promise<TapCharge>;

  getCharge(id: string): Promise<TapCharge>;

  refund(input: {
    chargeId: string;
    amount: number;
    currency: string;
    reason: string;
    orderId: string;
  }): Promise<{ id: string; status: string }>;

  verifyWebhookHash(body: TapCharge, signatureHeader: string | null): boolean;
}

export const tapPlugin = {
  name: "tap",
  register(app: App) {
    const client: TapClient = {
      createCharge({ amount, currency, description, orderId, customer, source, metadata }) {
        return call<TapCharge>("/charges", {
          method: "POST",
          body: JSON.stringify({
            amount,
            currency,
            description,
            statement_descriptor: description.slice(0, 22),
            reference: { transaction: orderId, order: orderId },
            receipt: { email: !!customer.email, sms: !!customer.phone },
            customer,
            source,
            post: { url: `${process.env.APP_URL}/webhooks/tap` },
            redirect: { url: `${process.env.APP_URL}/checkout/tap/return?order=${encodeURIComponent(orderId)}` },
            metadata,
          }),
        });
      },

      getCharge(id) {
        return call<TapCharge>(`/charges/${id}`);
      },

      refund({ chargeId, amount, currency, reason, orderId }) {
        return call("/refunds", {
          method: "POST",
          body: JSON.stringify({
            charge_id: chargeId,
            amount,
            currency,
            reason,
            reference: { merchant: orderId },
            post: { url: `${process.env.APP_URL}/webhooks/tap` },
          }),
        });
      },

      verifyWebhookHash(body, signatureHeader) {
        if (!signatureHeader) return false;
        const expected = createHmac("sha256", process.env.TAP_SECRET_KEY!)
          .update(buildHashString(body))
          .digest("hex");
        const a = Buffer.from(signatureHeader);
        const b = Buffer.from(expected);
        return a.length === b.length && timingSafeEqual(a, b);
      },
    };
    app.decorate("tap", client);
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    tap: TapClient;
  }
}

The field order inside buildHashString is the part Tap is strict about — keep it pinned to their webhook docs. If they add a new field to the hash, every webhook will fail until you update the function.

4. Create a hosted charge

The simplest integration: source.id: "src_all"gets you Tap's hosted checkout page with every method you've enabled. Use src_card, src_kw.knet, src_sa.mada, etc. to pin a specific method.

ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { tapPlugin } from "./plugins/tap";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 30 }));
app.register(tapPlugin);

app.route({
  method: "POST",
  path: "/checkout/tap",
  operationId: "createTapCharge",
  request: {
    body: z.object({
      orderId: z.string().min(1).max(80),
      amount: z.number().positive(),
      currency: z.enum(["KWD", "SAR", "AED", "BHD", "QAR", "OMR", "EGP", "USD"]),
      description: z.string().min(1).max(200),
      source: z.string().default("src_all"),
      customer: z.object({
        first_name: z.string().min(1).max(80),
        email: z.string().email().optional(),
      }),
    }),
  },
  responses: {
    201: {
      description: "redirect to Tap",
      body: z.object({ chargeId: z.string(), redirectUrl: z.string().url() }),
    },
  },
  handler: async ({ body, state }) => {
    const charge = await state.tap.createCharge({
      amount: body.amount,
      currency: body.currency,
      description: body.description,
      orderId: body.orderId,
      customer: body.customer,
      source: { id: body.source },
      metadata: { orderId: body.orderId },
    });
    const url = charge.transaction?.url;
    if (!url) throw new Error("Tap did not return a redirect URL");
    return { status: 201, body: { chargeId: charge.id, redirectUrl: url } };
  },
});

5. Webhook

Tap POSTs JSON for every charge state change. Verify the hashstring header, then refetch the charge before doing anything irreversible:

ts
app.route({
  method: "POST",
  path: "/webhooks/tap",
  operationId: "tapWebhook",
  responses: {
    200: { description: "ok", body: z.object({ ok: z.literal(true) }) },
    401: { description: "bad hash", body: z.object({ error: z.string() }) },
  },
  handler: async ({ request, state }) => {
    const event = (await request.json()) as Awaited<ReturnType<typeof state.tap.getCharge>>;
    const signature = request.headers.get("hashstring");

    if (!state.tap.verifyWebhookHash(event, signature)) {
      return { status: 401, body: { error: "bad hashstring" } };
    }

    // Refetch to defeat replay / stale-payload tricks.
    const charge = await state.tap.getCharge(event.id);
    const orderId = charge.metadata?.orderId ?? charge.reference?.order;

    if (charge.status === "CAPTURED" && orderId) {
      // Fulfil the order. Use idempotency on your side keyed by (orderId, charge.id).
    }

    return { status: 200, body: { ok: true as const } };
  },
});

6. Refunds

ts
await state.tap.refund({
  chargeId: "chg_xxx",
  amount: 10,
  currency: "KWD",
  reason: "requested_by_customer",
  orderId: "order_123",
});

Authorize + capture

For an auth-then-capture flow (useful for hotels, marketplaces, anything where the final amount is decided after the customer's session), use /v2/authorize instead of /v2/charges, then POST /v2/authorize/{id} with { status: "VOID" } or a capture body. Not every method supports it — confirm with Tap support per scheme before relying on it.

Runtimes

Everything here is plain fetch and node:crypto. Swap createHmac for crypto.subtleif you're targeting Cloudflare Workers or Vercel Edge — Tap itself has no runtime requirements beyond a TLS-capable HTTP client.

Errors

Tap returns JSON like { "errors": [{ "code": "1101", "description": "..." }] } with an HTTP error status. Map them through problem+json; the most common ones are 400 (bad body), 401 (wrong key or test/live mismatch), and 404 (asking for a charge that belongs to a different account).

Modernisation notes

  • Use src_all for the hosted page unless you need to pin a method. Saves you from maintaining a method-picker UI and lets Tap roll out new payment options without you redeploying.
  • Always re-fetch on webhook. The body is signed but webhooks get retried; treating GET /charges/{id}as the source of truth means out-of-order delivery can't flip a paid order back to pending.
  • Stop using charge_id from the redirect to fulfil orders. The ?tap_id=on the return URL is for showing the customer "thanks" — never for marking the order paid. Fulfillment belongs in the webhook handler.
  • Keep buildHashString in one place. If Tap changes the hash inputs you want to update one function, not eight call sites.

See also the payments overview, Adyen guide, Mollie guide, and problem+json errors.