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

# Authentication

> Two layers — HMAC for server-to-server, JWT for per-user sessions.

Zennopay uses two distinct auth layers in parallel. This mirrors the
Stripe / Razorpay pattern: a long-lived shared secret on the server side, a
short-lived per-session token on the client side.

| Layer           | Used between                              | Algorithm   | Lifetime                       |
| --------------- | ----------------------------------------- | ----------- | ------------------------------ |
| **HMAC**        | Partner backend ↔ Zennopay REST API       | HMAC-SHA256 | Long-lived (rotated quarterly) |
| **Session JWT** | Partner backend → SDK → Zennopay checkout | RS256       | ≤ 10 minutes, one-time-use     |

## 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.

### Required headers

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

<ParamField header="X-Zennopay-Timestamp" type="string" required>
  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. 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:

```
{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 body `{"amount_usd":3.45}`, 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.

### 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 signing secret — <your_secret>
    keyId: string;
  }) {
    const timestamp = new Date().toISOString();
    const nonce = crypto.randomBytes(16).toString("hex");
    const bodyHash = body
      ? crypto.createHash("sha256").update(body).digest("hex")
      : "";
    const canonical = [method, path, timestamp, nonce, bodyHash].join("\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(16)
      body_hash = hashlib.sha256(body).hexdigest() if body else ""
      canonical = "\n".join([method, path, timestamp, nonce, body_hash])
      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     | `a1b2c3d4e5f6789012345678abcdef00`                                  |
| Body      | `{"amount_usd":3.45,"corridor":"th_promptpay"}`                     |

Expected canonical request:

```text theme={null}
POST
/v1/payment_intents
2026-05-21T14:30:00Z
a1b2c3d4e5f6789012345678abcdef00
{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 JWT

For each user payment, your backend mints a JWT identifying that specific
user and that specific intent. The JWT travels from your backend → your
mobile app → the Zennopay SDK → `checkout.zennopay.com`.

### Algorithm — RS256

JWTs are signed with **RS256 (asymmetric)**. You sign with your RSA private
key; Zennopay verifies with your public key, fetched from your JWKS endpoint.

Rationale: asymmetric means Zennopay never holds your signing key. You can
rotate keys independently, and Zennopay can discover new keys automatically
by refreshing your JWKS.

<Warning>
  HS256 (symmetric) is **not** accepted. Sharing a JWT signing secret would
  conflate browser-visible trust with server-side trust.
</Warning>

### Required claims

All JWTs MUST include these claims. Missing any field = rejected.

| Claim                            | Type    | Description                                                                          |
| -------------------------------- | ------- | ------------------------------------------------------------------------------------ |
| `iss`                            | string  | Issuer URL, e.g. `https://api.your-domain.com`. Must match your registered JWKS URL. |
| `aud`                            | string  | Must be `"zennopay-checkout"`.                                                       |
| `sub`                            | string  | Your internal user ID (opaque to Zennopay).                                          |
| `iat`                            | integer | Issued-at, Unix epoch seconds.                                                       |
| `exp`                            | integer | Expiry. Must be ≤ `iat + 600` (10-minute max session).                               |
| `jti`                            | string  | Unique JWT ID. UUID v7 recommended. Enforced one-time-use.                           |
| `nbf`                            | integer | Not-before. Optional.                                                                |
| `zennopay:intent_id`             | string  | Zennopay intent ID this JWT authorizes. Format `zp_...`.                             |
| `zennopay:amount_usd_cents`      | integer | Authorized amount in USD cents.                                                      |
| `zennopay:corridor`              | string  | `"th_promptpay"` or `"vn_vietqr"`. Locks the route at issuance.                      |
| `zennopay:kyc_attestation`       | object  | `{ "verified": true, "method": "...", "verified_at": "..." }`                        |
| `zennopay:sanctions_attestation` | object  | `{ "clean": true, "screened_at": "..." }`                                            |

Zennopay-specific claims live under the `zennopay:` prefix to avoid
collision with future standard claims.

### Example JWT payload

```json theme={null}
{
  "iss": "https://api.your-domain.com",
  "aud": "zennopay-checkout",
  "sub": "your_user_xyz123",
  "iat": 1716305400,
  "exp": 1716305700,
  "jti": "0190a8b3-4c5d-7e6f-8a9b-c0d1e2f3a4b5",
  "zennopay:intent_id": "zp_AbCd1234EfGh5678",
  "zennopay:amount_usd_cents": 345,
  "zennopay:corridor": "th_promptpay",
  "zennopay:kyc_attestation": {
    "verified": true,
    "method": "your_kyc_v2",
    "verified_at": "2026-05-21T13:30:00Z"
  },
  "zennopay:sanctions_attestation": {
    "clean": true,
    "screened_at": "2026-05-21T14:25:00Z"
  }
}
```

### JWKS endpoint requirements

Publish your JWKS at the standard well-known path:

```
https://{your-domain}/.well-known/jwks.json
```

The response must include at least one active signing key with:

* `use: "sig"`
* `kty: "RSA"`
* `alg: "RS256"`
* `kid` matching the JWT header's `kid` claim
* Modulus `n` and exponent `e` (standard RSA public-key encoding)

During key rotation, publish both old and new keys for 14 days. Zennopay
caches your JWKS for up to 10 minutes; on a JWT with an unknown `kid`,
Zennopay invalidates the cache and refetches once before rejecting.

### Verification order

Zennopay's checkout web AND backend both verify each JWT in this order.
Any failure rejects the session:

1. JWT is a well-formed signed JWS (three segments).
2. Fetch the JWKS for the `iss` value; pick the key matching the JWT header's `kid`.
3. Verify the signature with the RSA public key.
4. `aud` equals `"zennopay-checkout"`.
5. `iss` matches the partner registered for the intent in the JWT.
6. `exp` is in the future. `nbf` (if present) is in the past. `iat` is not
   more than 15 minutes in the past.
7. `exp - iat ≤ 600` (10-minute session cap).
8. `jti` has not been consumed. On success it's stored for the remaining
   token lifetime — one-time use.
9. `intent_id` exists in Zennopay's database and is in state `created` or
   `authorized` (not `captured`, `failed`, or `expired`).
10. `amount_usd_cents` matches the stored intent's amount.
11. `corridor` matches the stored intent's corridor.

### Hash-fragment transport

The SDK passes the JWT to checkout web via the URL hash fragment:

```
https://checkout.zennopay.com/flow/zp_AbCd1234/scan#token={jwt}
```

The hash fragment is **never** sent to the server in HTTP requests (per the
URL spec). This means:

* No proxy or CDN logs the JWT.
* No `Referer` header leaks it.
* Zennopay's access logs do not contain it.

Checkout web parses the JWT from `window.location.hash`, validates it, then
strips the hash via `history.replaceState` so the JWT does not sit in browser
history.

## End-to-end flow

Both layers operate together in a real payment:

```text theme={null}
User taps "Pay" in partner app
    |
    v
Partner backend creates intent:
    POST https://api.zennopay.com/v1/payment_intents  (HMAC-signed)
    -> { "intent_id": "zp_AbCd1234", ... }
    |
    v
Partner backend mints session JWT (RS256):
    Header: { "kid": "your_prod_2026q2", "alg": "RS256" }
    Claims: aud=zennopay-checkout, exp=now+10min,
            zennopay:intent_id=zp_AbCd1234, ...
    |
    v
Partner backend returns to app: { intent_id, jwt }
    |
    v
Partner app calls SDK:
    Zennopay.openCheckout(intentID, jwt, returnScheme)
    |
    v
SDK opens https://checkout.zennopay.com/flow/zp_AbCd1234/scan#token=eyJ...
    |
    v
Checkout web:
    1. Parse JWT from hash, strip the hash
    2. Fetch partner JWKS (10-min cached)
    3. Verify JWT (all 11 checks above)
    4. User scans QR, confirms, slides to pay
    5. POST /v1/payment_intents/zp_AbCd1234/confirm
       Authorization: Bearer <the JWT>
       (Backend verifies the JWT again — defense in depth — and enforces
        one-time-use on jti)
    |
    v
Backend executes payment: debit FBO, call provider, settle
    |
    v
Checkout web redirects: yourapp://payment-result?intent_id=zp_AbCd1234&status=success
    |
    v
SDK returns control to partner app with PaymentResult
```

## What's out of scope for v1

* **Refresh tokens.** JWTs are one-shot. If the session expires, mint a new one.
* **Sandbox audience scoping.** v1.1 will add `aud: zennopay-checkout-test`
  for sandbox environments. For v1, sandbox uses the same `aud` value with
  sandbox-side JWKS routing.
* **Signed session cookies on checkout.** The JWT-in-hash is sufficient for
  the current single-page flow.
