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.
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:v1— the scheme version, literally the stringv1.APP_ID,KEY_ID— the same values you sent inX-AptaPay-Key.HOST— theHostheader, 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, joinedk=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.
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-incrypto.
signing.ts
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.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 withbody_too_large before the HMAC is even computed.
401 triage
Every signing failure returns401 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.
Rate limits
Requests are rate limited per tenant per minute. Exceeding the limit returnsrate_limited, which is retriable — back off and retry with the same
idempotency key. See Errors.