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.

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:
For POST /v1/payment_intents at 2026-05-21T14:30:00Z with nonce a1b2c3d4e5f6... and a JSON body, the canonical request is:
For partner API calls, 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.
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. Signature: reconstruct the canonical request, recompute HMAC, compare in constant time.
  5. Nonce uniqueness: not seen in the replay window.
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.

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

QR Payments app package allowlist

The session token authorizes requests for a user and intent. The app package allowlist checks the X-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.
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.

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.