Authentication
InfraIO Pay has two API surfaces with different auth models. Pick the one that matches who’s calling:
| Surface | Path prefix | Audience | Auth |
|---|---|---|---|
| Merchant B2B | /b2b/v1/* | Your server | HMAC-SHA256 request signing |
| Dashboard | Per-service: /auth/*, /payment/*, /merchant/*, /event/*, /user/*, … | Browser sessions for the merchant dashboard | Bearer JWT |
This page covers the B2B surface — the one you call from your server with an API key pair. If you’re embedding the InfraIO dashboard or building internal tooling, use the dashboard surface (separate docs, not yet public).
The gateway routes each surface by a leading prefix that it strips
before forwarding: /b2b/v1/checkout-sessions/quick reaches
payment-service as /v1/checkout-sessions/quick, and the dashboard’s
/payment/v1/orders reaches it as /v1/orders. So if you see bare
/v1/* paths elsewhere, that’s the backend-internal path after the
public prefix has been removed — your client always sends the
prefixed form. (One consequence for signing: the B2B canonical string
signs the path with the /b2b prefix still attached — see below.)
Endpoints
| Environment | Base URL |
|---|---|
| Test | https://api-dev.infraio.xyz |
| Live | https://api.infraio.xyz |
Same URL pattern — environment is controlled by the key prefix
(pk_test_… vs pk_live_…), not the URL.
Key pair
You get two values from the merchant dashboard (Developers → API keys → + Add key):
- Publishable key (
pk_test_…orpk_live_…) — identifies your account. Sent asX-Client-ID. Safe to embed in your browser bundle (the SDK already does). - Secret key (
sk_test_…orsk_live_…) — the HMAC signing key. Server-only. Treat it like a database password.
If a secret key ever lands in a browser bundle, git repo, log line, or shared chat — revoke it immediately from the dashboard. There is no overlap window; revocation is instant. Issue a new key and redeploy.
Signing a request
Every call to /b2b/v1/* carries three headers:
X-Client-ID: pk_live_70d0becbd7a53047b0dfe88a960d10ad
X-Timestamp: 1715990400
X-Signature: 9a8b7c6d… (hex HMAC-SHA256)The signature is computed over a canonical string:
METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + BODYMETHOD— uppercase HTTP verb (POST,GET, …).PATH— request path including the/b2bprefix, without the host and without the query string (e.g./b2b/v1/checkout-sessions/quick). The gateway verifies the signature over the raw incoming path before stripping/b2b, so the prefix must be present. Query parameters are not signed — for aGET …?cursor=…&limit=20, sign only the path, not the?…part.TIMESTAMP— unix seconds, as decimal string (e.g."1715990400"), matchingX-Timestampexactly.BODY— raw request body bytes. Empty string forGET/DELETE.
Sign with HMAC-SHA256 keyed by the secret key, output hex:
Node / TS
import { createHmac } from "node:crypto";
function sign({ method, path, body, secret }: {
method: string; path: string; body: string; secret: string;
}) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const input = [method.toUpperCase(), path, timestamp, body].join("\n");
const signature = createHmac("sha256", secret).update(input).digest("hex");
return { timestamp, signature };
}Why HMAC, not Bearer?
A bare Bearer-token API ships your only secret over the wire on every request. Anyone who captures one TLS-terminated proxy log gets the keys to your account. HMAC signing means the secret never travels — only its derived signature, which is single-use (bound to that exact request + that exact minute).
The trade-off: you have to compute the signature for every call. A server SDK would hide this; until we publish one, the helper above is ~15 lines per language.
Timestamp tolerance
The binding tolerance is ±5 minutes (300 seconds), enforced by
merchant-service when it verifies the signature. The gateway itself
is slightly looser (310s) as defence-in-depth, but a request that
passes the gateway and fails the inner check still ends in
401 invalid_signature — assume 300s as the contract. Two
implications:
- Sync your server clock with NTP. A long-running cron with a drifted clock will fail intermittently.
- Don’t pre-compute and queue signatures. If a request sits in a retry queue for >5 min, its signature expires.
Key scopes
Secret keys carry one or more of these scope bundles:
| Scope | Intended use |
|---|---|
read | List/read orders, sessions, refunds |
write_order | Create checkout sessions, orders |
write_refund | Issue refunds, mint refund-request tokens |
webhook_manage | Create/update/delete webhook endpoints |
The dashboard issues a “full access” key by default (all four scopes). You can mint a restricted-scope key from Developers → API keys → + Add key and tick only the scopes the integration needs.
Scope enforcement is currently advisory, not gated. The
scopes are recorded on the key and shown back to you in the
dashboard, but gateway middleware does not yet reject out-of-scope
calls — any valid sk_… key behaves as full-access today.
Per-endpoint scope gating is on the next release. Don’t lean on
scopes as a security boundary yet; treat them as labels and rotate
/ revoke keys to restrict access in the meantime.
Where the signature is verified
HMAC validation happens once, at the gateway. The gateway:
- Reads
X-Client-ID,X-Timestamp,X-Signature. - Looks up the merchant + secret by
pk_…, runs the timestamp window check, recomputes the signature, constant-time compares. - On success, strips the auth headers, stamps the request with
internal headers (
X-B2B-Auth: 1,X-Merchant-ID,X-Merchant-Domain) and forwards to the downstream service (payment-service, merchant-service, etc.). Environment + resolved scopes are NOT injected today — downstream code that needs the environment derives it from the request body / per-merchant config, not from headers. - On failure, returns 401
INVALID_SIGNATUREwithout ever touching the backend.
Downstream services do not re-run HMAC — they trust the gateway’s
injected headers and act on the merchant the gateway resolved. They do
not scope-gate per endpoint either: as noted above, the key’s
scope isn’t injected, so any authenticated sk_… reaches any endpoint
for its merchant (scope enforcement is advisory today — see the
callout under Key scopes). This matters in two ways:
- If you operate your own reverse proxy in front of InfraIO Pay, do
not strip
X-B2B-Auth/X-Merchant-ID(and don’t forge them either — the gateway rejects inbound requests that carry these on the public edge). - Public-network paths (
/b2b/v1/*) are the only surface that runs the HMAC step. Internal gRPC between our services uses mTLS — a different trust model that doesn’t acceptX-Client-ID.
What’s next
- Errors — response shape on 4xx/5xx.
- Security → API keys — rotation, revocation, what to do if a secret leaks.
- Webhooks → Signature verification
— uses a different HMAC scheme (header
X-Signature: sha256=…, signsX-Timestamp + "." + raw_body, plus an optionalX-Signature-Prevduring the 24-hour rotation grace window). Don’t mix the schemes up — they share the hash algorithm but the signed bytes and the secret family (whsec_…vssk_…) are different.