Skip to main content
Every request to AptaPay is signed. There is no login call and no token endpoint — you do not trade credentials for a session. You compute a signature per request, and it is valid for seconds.
If you are debugging a stubborn 401, jump straight to 401 triage. The server can only ever answer 401 for any signing failure, so the cause is never in the response body.

Your credentials

A tenant credential set is four values, issued together, per mode: You are also issued an outbound signing secret, which is a separate value used only to verify webhooks we send you. See Webhooks.
Live and test are completely separate credential sets. A tenant gets two. Never put either set in source control — store them in your secret manager.

The four headers

Every request except /v1/webhooks/* carries these:
X-AptaPay-Key packs three values with . as the separator. Only the third is secret.

The canonical string

The signature is an HMAC-SHA256, hex-encoded, over ten fields joined with newlines, in exactly this order:
Field by field:
  • v1 — the scheme version, literally the string v1.
  • APP_ID, KEY_ID — the same values you sent in X-AptaPay-Key.
  • HOST — the Host header, lowercased, with no port unless your base URL includes one.
  • METHOD — uppercase (GET, POST, PUT, PATCH, DELETE).
  • PATH — the URL path only. No query string. Use the undecoded path exactly as it goes on the wire.
  • CANONICAL_QUERY — every query parameter, RFC3986-encoded, sorted by name then value, joined k=v&k=v. Empty string when there is no query.
  • TIMESTAMP — Unix seconds as a 10-digit string, matching the header.
  • NONCE — matching the header. Fresh and unpredictable per request.
  • SHA256_HEX(RAW_BODY) — SHA-256 hex digest of the exact raw bytes of the body. For a GET or DELETE with no body this is the well-known empty-string digest: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
A GET whose query differs from what you signed will not verify. This is deliberate: a captured read signature must not be replayable against a different query, so ?limit=1 and ?limit=100000 do not share a signature.

Reference implementation

This is the same primitive the gateway uses internally, so signing and verification are provably one scheme rather than two implementations that happen to agree today. It needs nothing but Node’s built-in crypto.
signing.ts
Using it for a charge:
charge.ts
Two secrets, never one. The secret that signs your outbound requests to AptaPay and the secret AptaPay uses to sign callbacks to you are separate values, so a leak in one direction does not compromise the other.

Timestamp window

Your timestamp must be within −120s to +30s of AptaPay’s clock. The window is intentionally asymmetric: there is no legitimate reason for a client to sign meaningfully into the future.
A skewed clock reads exactly like a bad secret. If signing worked yesterday and fails today on an unchanged integration, check NTP before you check credentials.

Nonces and replay

A nonce cannot be reused inside the freshness window. Use a fresh, unpredictable value per request — a UUID with dashes stripped works well. Replaying a captured raw request is rejected. The nonce is claimed only after the HMAC verifies, so unauthenticated traffic cannot force writes on our side.

Body size

Signed request bodies are capped at 256 KB. Anything larger is rejected with body_too_large before the HMAC is even computed.

401 triage

Every signing failure returns 401 with no detail, by design. Work down this list in order:
1

Check your clock

Run date -u on the calling machine and compare to real UTC. Outside −120s/+30s, nothing else matters.
2

Confirm you signed the raw bytes

The body hash must cover the exact bytes you transmit. Serializing the object twice — once to sign, once to send — produces different bytes if key order or whitespace differs. Serialize once into a Buffer, sign that buffer, send that buffer.
3

Check the host field

Lowercased, and the port is included only when your base URL has one. Signing api.pay.aptahq.com:443 will not verify.Sign the host you actually dial. The host is part of the canonical string, so it must match your base URL exactly. A mismatch produces a 401 invalid_signature that looks like a bad key and is not one.
4

Check the query string

Sorted by name then value, RFC3986-encoded, and empty string when there is no query — not omitted, not a ?.
5

Confirm the nonce is fresh

A duplicated nonce inside the window is a replay rejection, which also surfaces as 401.
6

Confirm you are using the right secret for the mode

Live and test sets are separate. A test key against a live base URL fails.
The fastest way to find a mismatch is the Postman collection, which signs every request for you. Set debug_signing to true and it prints the canonical string to the Postman console, so you can diff it against yours. See Testing.

Rate limits

Requests are rate limited per tenant per minute. Exceeding the limit returns rate_limited, which is retriable — back off and retry with the same idempotency key. See Errors.