Skip to main content
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.
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:
For POST /v1/payment_intents at 2026-05-21T14:30:00Z with nonce a1b2c3d4e5f6... and a JSON body, the canonical request is:
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.
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:
  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. Expected canonical request:
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.

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

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.