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.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.
| 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 tohttps://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
Identifies which shared-secret key was used to sign. Example:
wizz_prod_2026q1. Format: {partner}_{env}_{quarter}.RFC 3339 UTC datetime, e.g.
2026-05-21T14:30:00Z. Requests more than 5
minutes off server time are rejected.Random 32-byte hex string. Used to reject duplicate-nonce replays within a
10-minute window.
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:
POST /v1/payment_intents at 2026-05-21T14:30:00Z with nonce
a1b2c3d4e5f6... and body {"amount_usd":3.45}, the canonical request is:
X-Zennopay-Signature.
Verification order
On each incoming request, Zennopay verifies in this order. Any failure returns401 authentication_failed:
- IP allowlist: source IP must match your registered list.
- Key ID:
X-Zennopay-Key-Idmust exist, be active, and not revoked. - Timestamp skew: within ±5 minutes of server time.
- Nonce uniqueness: not seen in the last 10 minutes.
- Signature: reconstruct the canonical request, recompute HMAC, compare in constant time.
Reference implementation
Key rotation
Each partner can hold up to 3 active keys at a time. To rotate:- Request a new key. We issue a new key ID (
{partner}_{env}_{nextquarter}). - Update your backend to use the new key. Both old and new keys remain valid during the transition.
- After 14 days, confirm migration. The old key is revoked.
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"} |
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 therequest_id to correlate with internal logs when escalating.
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.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: prefix to avoid
collision with future standard claims.
Example JWT payload
JWKS endpoint requirements
Publish your JWKS at the standard well-known path:use: "sig"kty: "RSA"alg: "RS256"kidmatching the JWT header’skidclaim- Modulus
nand exponente(standard RSA public-key encoding)
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:- JWT is a well-formed signed JWS (three segments).
- Fetch the JWKS for the
issvalue; pick the key matching the JWT header’skid. - Verify the signature with the RSA public key.
audequals"zennopay-checkout".issmatches the partner registered for the intent in the JWT.expis in the future.nbf(if present) is in the past.iatis not more than 15 minutes in the past.exp - iat ≤ 600(10-minute session cap).jtihas not been consumed. On success it’s stored for the remaining token lifetime — one-time use.intent_idexists in Zennopay’s database and is in statecreatedorauthorized(notcaptured,failed, orexpired).amount_usd_centsmatches the stored intent’s amount.corridormatches the stored intent’s corridor.
Hash-fragment transport
The SDK passes the JWT to checkout web via the URL hash fragment:- No proxy or CDN logs the JWT.
- No
Refererheader leaks it. - Zennopay’s access logs do not contain it.
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: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-testfor sandbox environments. For v1, sandbox uses the sameaudvalue with sandbox-side JWKS routing. - Signed session cookies on checkout. The JWT-in-hash is sufficient for the current single-page flow.