> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zennopay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> One credential for partners — an HMAC signing secret. Zennopay mints the client session token for you.

Zennopay gives you a single credential to manage: an **HMAC signing secret**.
Your backend signs every REST call with it. When you create a payment intent,
Zennopay mints a short-lived **session token** and returns it to you — you never
generate, sign, publish, or register a key pair.

| Layer             | Used between                          | Algorithm                      | Who holds it                     |
| ----------------- | ------------------------------------- | ------------------------------ | -------------------------------- |
| **HMAC**          | Partner backend ↔ Zennopay REST API   | HMAC-SHA256                    | Your server (rotated quarterly)  |
| **Session token** | Partner app → SDK → Zennopay checkout | Zennopay-minted, opaque to you | Your app, in memory, short-lived |

<Note>
  **No key pair required.** There is no RS256 keypair to generate, no
  `iss`/`kid` to register, no JWKS endpoint to publish, and no session JWT for
  you to sign. Zennopay issues the session token.
</Note>

## Server-to-server HMAC

Every request to the Zennopay REST API (`https://api.zennopay.com/v1/*` in
production, `https://api.sandbox.zennopay.com/v1/*` in sandbox — see
[Environments](/api-reference/environments)) from your backend MUST be signed
with HMAC-SHA256 and accompanied by four headers, and must originate from an
allowlisted source IP.

The legacy `api.zennopay.in` / `api.sandbox.zennopay.in` hosts remain served for
existing integrations, with identical signing rules — the host is not part of
the signing string, so no signature changes either way.

### Required headers

<ParamField header="X-Zennopay-Key-Id" type="string" required>
  Identifies which shared-secret key was used to sign. Example:
  `acme_prod_2026q1`. Format: `{partner}_{env}_{quarter}`.
</ParamField>

<ParamField header="X-Zennopay-Timestamp" type="string" required>
  ISO-8601 / RFC 3339 UTC datetime, e.g. `2026-05-21T14:30:00Z`. Requests more
  than 5 minutes off server time are rejected.
</ParamField>

<ParamField header="X-Zennopay-Nonce" type="string" required>
  Random 32-byte hex string (64 chars). Used to reject duplicate-nonce replays
  within a 10-minute window.
</ParamField>

<ParamField header="X-Zennopay-Signature" type="string" required>
  Base64-encoded HMAC-SHA256 of the canonical request string (defined below).
</ParamField>

### Canonical request

The string you sign is constructed by joining these five components with a
single newline (`\n`) between each, **including a trailing newline** after the
body hash:

```
{HTTP_METHOD}\n
{REQUEST_PATH}\n
{X-Zennopay-Timestamp}\n
{X-Zennopay-Nonce}\n
{SHA256_HEX of request body, or empty string for GET/DELETE}\n
```

For `POST /v1/payment_intents` at `2026-05-21T14:30:00Z` with nonce
`a1b2c3d4e5f6...` and a JSON body, the canonical request is:

```text theme={null}
POST
/v1/payment_intents
2026-05-21T14:30:00Z
a1b2c3d4e5f6...
8b7e6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b
```

The last line is the lowercased SHA256 hex of the exact bytes of the request
body. Sign this string with HMAC-SHA256 using your signing secret, then
base64-encode the output and pass it as `X-Zennopay-Signature`.

<Warning>
  Whitespace matters. Partner controls the exact byte-level JSON serialization
  — Zennopay verifies against the bytes you actually send. Do not re-serialize
  the body between signing and transmission.
</Warning>

### Verification order

On each incoming request, Zennopay verifies in this order. Any failure returns
`401 authentication_failed`:

1. **IP allowlist:** source IP must match your registered list.
2. **Key ID:** `X-Zennopay-Key-Id` must exist, be active, and not revoked.
3. **Timestamp skew:** within ±5 minutes of server time.
4. **Nonce uniqueness:** not seen in the last 10 minutes.
5. **Signature:** reconstruct the canonical request, recompute HMAC, compare
   in constant time.

If all five pass, the request is authenticated and the partner ID is attached
to the request context for authorization checks.

<Note>
  **The IP allowlist is enforced in production only.** Sandbox accepts
  server-to-server calls from any source IP, so you can integrate from dev and
  CI without registering ranges. Register your production egress IPs in the
  [Console](https://console.zennopay.com) before going live. Until you register
  any, the check does not restrict you — so a rollout never locks out an
  existing integration. The **app** side (SDK calls) is guarded separately by
  the [app package allowlist](#app-package-allowlist) below, in **both**
  environments.
</Note>

### Reference implementation

<CodeGroup>
  ```typescript Node theme={null}
  import crypto from "node:crypto";

  function signRequest({ method, path, body, secret, keyId }: {
    method: string;
    path: string;
    body: string; // exact JSON bytes you will send
    secret: string; // your HMAC signing secret — <your_secret>
    keyId: string;
  }) {
    const timestamp = new Date().toISOString();
    const nonce = crypto.randomBytes(32).toString("hex"); // 64 hex chars
    const bodyHash = body
      ? crypto.createHash("sha256").update(body).digest("hex")
      : "";
    const canonical = [method, path, timestamp, nonce, bodyHash].join("\n") + "\n";
    const signature = crypto
      .createHmac("sha256", secret)
      .update(canonical)
      .digest("base64");
    return {
      "X-Zennopay-Key-Id": keyId,
      "X-Zennopay-Timestamp": timestamp,
      "X-Zennopay-Nonce": nonce,
      "X-Zennopay-Signature": signature,
    };
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, base64, secrets
  from datetime import datetime, timezone

  def sign_request(method: str, path: str, body: bytes, secret: str, key_id: str):
      timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
      nonce = secrets.token_hex(32)  # 64 hex chars
      body_hash = hashlib.sha256(body).hexdigest() if body else ""
      canonical = "\n".join([method, path, timestamp, nonce, body_hash]) + "\n"
      signature = base64.b64encode(
          hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest()
      ).decode()
      return {
          "X-Zennopay-Key-Id": key_id,
          "X-Zennopay-Timestamp": timestamp,
          "X-Zennopay-Nonce": nonce,
          "X-Zennopay-Signature": signature,
      }
  ```
</CodeGroup>

### Key rotation

Each partner can hold up to 3 active keys at a time. To rotate:

1. Request a new key. We issue a new key ID (`{partner}_{env}_{nextquarter}`).
2. Update your backend to use the new key. Both old and new keys remain
   valid during the transition.
3. After 14 days, confirm migration. The old key is revoked.

Revoked keys are kept in an immutable tombstone store for audit. They never
re-authenticate.

### Test vectors

Use these to validate your client implementation before sending real traffic.

| Field     | Value                                                               |
| --------- | ------------------------------------------------------------------- |
| Key ID    | `test_key_001`                                                      |
| Secret    | `<your_secret>` *(use the sandbox secret issued during onboarding)* |
| Method    | `POST`                                                              |
| Path      | `/v1/payment_intents`                                               |
| Timestamp | `2026-05-21T14:30:00Z`                                              |
| Nonce     | `a1b2c3d4e5f6789012345678abcdef00a1b2c3d4e5f6789012345678abcdef00`  |
| Body      | `{"amount_usd_cents":345,"corridor":"th_promptpay"}`                |

Expected canonical request:

```text theme={null}
POST
/v1/payment_intents
2026-05-21T14:30:00Z
a1b2c3d4e5f6789012345678abcdef00a1b2c3d4e5f6789012345678abcdef00
{SHA256_HEX_OF_BODY}
```

Where `SHA256_HEX_OF_BODY` is the SHA256 hex of the exact bytes of the JSON
body. Your client should produce the same canonical string and signature
that Zennopay computes; if it doesn't, signature verification will fail.

<Note>
  The expected base64 signature is published in the sandbox onboarding email
  with the actual sandbox secret. Do not hard-code production secrets into
  your test suite — use `<your_secret>` placeholders and inject the real
  value from your secret manager.
</Note>

### Errors

All 401 responses use a generic body to prevent enumeration of failure
reasons. Use the `request_id` to correlate with internal logs when escalating.

```json theme={null}
{
  "error": {
    "code": "authentication_failed",
    "message": "Request signature could not be verified.",
    "request_id": "req_a1b2c3..."
  }
}
```

## Client session token

Your app never holds your HMAC secret. Per payment, it holds a **session token**
— a short-lived credential scoped to a single intent — which it passes to the
Zennopay SDK.

You don't mint this token — **Zennopay does**. When your backend creates the
payment intent, the response includes:

| Field                | Meaning                                                                                                                                    |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `session_token`      | The credential the SDK sends as `Authorization: Bearer` on `scan` / `confirm` / status calls. Opaque to you — treat it as a bearer secret. |
| `session_expires_at` | Unix epoch **seconds** at which the token stops verifying. Typically a few minutes out.                                                    |

The token is single-intent, short-lived, and single-use for the confirming
debit. It is verified by Zennopay on every SDK call.

### Getting a session token

Create the intent with a normal HMAC-signed call. The
[create response](/api-reference/payment-intents/create) carries the token:

```json theme={null}
{
  "intent_id": "zp_AbCd1234EfGh5678",
  "status": "created",
  "amount_usd_cents": 345,
  "corridor": "th_promptpay",
  "session_token": "zpst_9b1f…",
  "session_expires_at": 1716305700,
  "created_at": "2026-05-21T14:30:00Z"
}
```

Return `session_token` (and, if you like, `session_expires_at`) to your app,
which hands it to the [PaymentSheet](/payments/overview). The token is a
short-lived bearer credential — treat it as opaque and pass it straight
through; don't parse or depend on its structure.

### Re-minting a session token

If the session expires mid-flow, don't create a new intent — re-mint a token
for the **same** intent. Send the same per-payment identity and attestations you
sent on create; Zennopay mints a fresh token from them:

```
POST /v1/payment_intents/{intent_id}/session   (HMAC-signed)

body: { partner_user_id, kyc_attestation, sanctions_attestation }
```

The response is a fresh `session_token` + `session_expires_at`. Zennopay
preserves the intent's scan / quote / confirm state across the re-mint. This is
exactly what the SDK's `refreshSession` hook calls your backend to do.

### Attestations move to intent creation

Because Zennopay mints the token, the per-payment compliance attestations you
own — **KYC** and **sanctions** — travel in the HMAC-signed
[create-intent body](/api-reference/payment-intents/create), not in a token you
sign:

```json theme={null}
{
  "partner_user_id": "usr_8f3ka92m",
  "amount_usd_cents": 345,
  "corridor": "vn_vietqr",
  "kyc_attestation": {
    "verified": true,
    "method": "your_kyc_v2",
    "verified_at": "2026-05-21T13:30:00Z",
    "id_type": "passport",
    "id_country": "IN"
  },
  "sanctions_attestation": {
    "clean": true,
    "screened_at": "2026-05-21T14:25:00Z"
  }
}
```

<Note>
  **Why attestations are per-payment.** You own KYC and sanctions screening for
  your users; Zennopay owns them for merchants. Sending the attestation with the
  signed create call makes the compliance handoff explicit, per-payment, and
  auditable — Zennopay will not move money on an intent that doesn't carry them.
  `id_type` / `id_country` declare which government ID your KYC bound this user
  to; the raw ID number itself never crosses.
</Note>

## App package allowlist

The session token authenticates the **user + intent**. The **app package
allowlist** authenticates the **app** that presents it: only your registered
mobile app can drive the SDK calls (`scan` / `confirm`), so a leaked session
token can't be replayed from a different app.

Every SDK call on the session-token path sends the app's platform identifier in
the `X-Zennopay-Package` header:

| Platform | Value                         | Example           |
| -------- | ----------------------------- | ----------------- |
| iOS      | Bundle identifier             | `com.acme.wallet` |
| Android  | Application ID (package name) | `com.acme.wallet` |

The SDK sets this header for you — you don't construct it. Your only step is to
**register your app's bundle id / package name in the
[Console](https://console.zennopay.com)**. A session-token call whose
`X-Zennopay-Package` isn't on your allowlist is rejected with
`401 authentication_failed`.

<Note>
  **Enforced in both sandbox and production** — unlike the IP allowlist
  (production-only), package identity is what distinguishes your app in every
  environment. Until you register any package, the check does not restrict you
  (so onboarding isn't blocked); once you register one, only registered app ids
  are accepted. The HMAC server-to-server path is **not** package-gated — it's
  governed by the IP allowlist instead.
</Note>

<Warning>
  Register the app ids for **every** flavor/variant you ship — e.g. a separate
  debug/staging application id (`com.acme.wallet.dev`) — or calls from those
  builds will 401 in sandbox.
</Warning>

## End-to-end flow

```text theme={null}
User taps "Pay" in partner app
    |
    v
Partner backend creates intent (HMAC-signed):
    POST https://api.zennopay.com/v1/payment_intents
    body: { partner_user_id, amount_usd_cents, corridor,
            kyc_attestation, sanctions_attestation }
    header: Idempotency-Key
    -> { "intent_id": "zp_AbCd1234", "session_token": "zpst_…",
         "session_expires_at": 1716305700, ... }
    |
    v
Partner backend returns to app: { intent_id, session_token }
    |
    v
Partner app calls SDK:
    Zennopay.presentCheckout(intentID, sessionToken)
    |
    v
SDK talks to Zennopay directly with session_token as Authorization: Bearer
(and the app's X-Zennopay-Package header, checked against your allowlist):
    1. User scans QR   -> POST /v1/payment_intents/:id/scan
    2. Amount + FX quote
    3. Slide to pay     -> POST /v1/payment_intents/:id/confirm (Idempotency-Key)
    (On 401 mid-flow, the SDK's refreshSession hook asks your backend to
     re-mint via POST /v1/payment_intents/:id/session.)
    |
    v
Zennopay executes payment: debit float, call payout partner, settle
    |
    v
SDK returns one typed PaymentResult to your app
    |
    v
Zennopay POSTs a signed webhook with the terminal status
```

## What's out of scope for v1

* **Refresh of the HMAC secret without a rotation window.** Rotation is the
  quarterly, dual-key process above.
* **Client-held API keys.** The mobile app never holds your HMAC secret; it only
  ever holds a Zennopay-minted session token scoped to one intent.
