Product use
The HMAC contract below is shared. Session tokens, receipt tokens, and app
package allowlisting apply only to QR Payments.
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
live mode, 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.
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:
REQUEST_PATH is the URL pathname, such as
/v1/payouts; exclude the host and query string. Use an uppercase HTTP method.
For outbound webhook verification, use the endpoint pathname including its
query string, if configured. See Webhooks.
The last line is the lowercased SHA256 hex of the exact bytes of a non-empty
request body. If there is no body, use an empty string for this line, including
for a bodyless POST. Sign this string with HMAC-SHA256 using your signing secret, then
base64-encode the output and pass it as 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.
- Signature: reconstruct the canonical request, recompute HMAC, compare in constant time.
- Nonce uniqueness: not seen in the replay window.
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.
QR Payments 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.QR Payments app package allowlist
The session token authorizes requests for a user and intent. The app package allowlist checks theX-Zennopay-Package identifier sent by the SDK against
your registered app identifiers. This header check is not device or app
attestation; continue to protect session tokens as bearer credentials.
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.
Product integration guides
Authentication defines credentials and signing only. Follow the product guide for request order, resource states, and retry behavior: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.