# Accept payments with Tap Payments in DaloyJS

[Tap Payments](https://www.tap.company/) 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](https://developers.tap.company/reference/api-endpoint) 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](https://tap.company/) and sign in to the [dashboard](https://os.tap.company/).
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](https://developers.tap.company/docs/webhook). If they add a new field to the hash, every webhook will fail until you update the function.

## 4. Create a hosted charge

**Diagram: Hosted charge flow**

Participants: Customer, DaloyJS route, Tap

1. **Customer -> DaloyJS route** (request) - POST /checkout/tap - orderId + amount + source { id: 'src_all' }
2. **DaloyJS route -> Tap** (request) - POST /v2/charges with post + redirect URLs - Authorization: Bearer sk_...
3. **DaloyJS route -> Customer** (response) - Return transaction.url - 201 { redirectUrl }
4. **Customer -> Tap** (async) - Pays on the hosted page, returns with ?tap_id=chg_xxx - redirect is a UX hint, not proof

Create the charge with src_all for the hosted page, redirect the customer to transaction.url, and treat the ?tap_id on return as a hint only. The webhook plus a refetch is what marks an order paid.

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.ts";

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

app.post(
  "/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.email().optional(),
        }),
      }),
    },
    responses: {
      201: {
        description: "redirect to Tap",
        body: z.object({ chargeId: z.string(), redirectUrl: z.url() }),
      },
    },
  },
  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

**Diagram: Webhook verification**

Participants: Tap, DaloyJS route

1. **Tap -> DaloyJS route** (request) - POST /webhooks/tap - hashstring header over ordered fields
2. **DaloyJS route -> DaloyJS route** (note) - Rebuild buildHashString, HMAC-SHA256 with the secret key - timingSafeEqual vs the hashstring header
3. **DaloyJS route -> Tap** (response) - 401 when the hash does not match - { error: 'bad hashstring' }
4. **DaloyJS route -> Tap** (async) - GET /v2/charges/{id} to confirm CAPTURED, then ack - 200, fulfil on the refetched status

Recompute the hashstring with the same field order Tap documents, reject mismatches with 401, then refetch the charge so a replayed or out-of-order delivery cannot flip a paid order back to pending.

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

```ts
app.post(
  "/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() }) },
    },
  },
  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.subtle` if you're targeting Cloudflare Workers, 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](/docs/errors); 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](/docs/payments), [Adyen guide](/docs/payments/adyen), [Mollie guide](/docs/payments/mollie), and [problem+json errors](/docs/errors).

---

Source: https://daloyjs.dev/docs/payments/tap