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.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) 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
string
required
Identifies which shared-secret key was used to sign. Example:
acme_prod_2026q1. Format: {partner}_{env}_{quarter}.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.string
required
Random 32-byte hex string (64 chars). Used to reject duplicate-nonce replays
within a 10-minute window.
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, including a trailing newline after the
body hash:
POST /v1/payment_intents at 2026-05-21T14:30:00Z with nonce
a1b2c3d4e5f6... and a JSON body, 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.
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 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 below, in both
environments.
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.
Expected canonical request:
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 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:
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 carries the token:session_token (and, if you like, session_expires_at) to your app,
which hands it to the PaymentSheet. 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: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, not in a token you sign: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.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:
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. A session-token call whose
X-Zennopay-Package isn’t on your allowlist is rejected with
401 authentication_failed.
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.
End-to-end flow
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.