Skip to main content

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.

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.
LayerUsed betweenAlgorithmLifetime
HMACPartner backend ↔ Zennopay REST APIHMAC-SHA256Long-lived (rotated quarterly)
Session JWTPartner backend → SDK → Zennopay checkoutRS256≤ 10 minutes, one-time-use

Server-to-server HMAC

Every request to https://api.zennopay.com/v1/* from your backend MUST be signed with HMAC-SHA256 and accompanied by four headers, and must originate from an allowlisted source IP.

Required headers

X-Zennopay-Key-Id
string
required
Identifies which shared-secret key was used to sign. Example: wizz_prod_2026q1. Format: {partner}_{env}_{quarter}.
X-Zennopay-Timestamp
string
required
RFC 3339 UTC datetime, e.g. 2026-05-21T14:30:00Z. Requests more than 5 minutes off server time are rejected.
X-Zennopay-Nonce
string
required
Random 32-byte hex string. Used to reject duplicate-nonce replays within a 10-minute window.
X-Zennopay-Signature
string
required
Base64-encoded HMAC-SHA256 of the canonical request string (defined below).

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

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

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,
  };
}

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.
FieldValue
Key IDtest_key_001
Secret<your_secret> (use the sandbox secret issued during onboarding)
MethodPOST
Path/v1/payment_intents
Timestamp2026-05-21T14:30:00Z
Noncea1b2c3d4e5f6789012345678abcdef00
Body{"amount_usd":3.45,"corridor":"th_promptpay"}
Expected canonical request:
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.
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.

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.
{
  "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.
HS256 (symmetric) is not accepted. Sharing a JWT signing secret would conflate browser-visible trust with server-side trust.

Required claims

All JWTs MUST include these claims. Missing any field = rejected.
ClaimTypeDescription
issstringIssuer URL, e.g. https://api.your-domain.com. Must match your registered JWKS URL.
audstringMust be "zennopay-checkout".
substringYour internal user ID (opaque to Zennopay).
iatintegerIssued-at, Unix epoch seconds.
expintegerExpiry. Must be ≤ iat + 600 (10-minute max session).
jtistringUnique JWT ID. UUID v7 recommended. Enforced one-time-use.
nbfintegerNot-before. Optional.
zennopay:intent_idstringZennopay intent ID this JWT authorizes. Format zp_....
zennopay:amount_usd_centsintegerAuthorized amount in USD cents.
zennopay:corridorstring"th_promptpay" or "vn_vietqr". Locks the route at issuance.
zennopay:kyc_attestationobject{ "verified": true, "method": "...", "verified_at": "..." }
zennopay:sanctions_attestationobject{ "clean": true, "screened_at": "..." }
Zennopay-specific claims live under the zennopay: prefix to avoid collision with future standard claims.

Example JWT payload

{
  "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:
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.