Authentication
Every v2 request is signed with HMAC-SHA256. A stolen key by itself can't be used to dispatch orders — you also need the secret, and stolen signatures expire in 30 seconds.
The three headers
Every request carries these headers. Get any wrong and the request fails with 401.
Authorization requiredstringKey, a space, then the API key value (starts with main_). Example: Authorization: Key main_d7b82c8f... x-armada-timestamp requiredstring (ms epoch)x-armada-signature requiredstring (hex)Content-Type optionalstringapplication/json when a body is sent. The SDKs set this for you. Computing the signature
Build a canonical payload string, then HMAC-SHA256 it with the secret:
payload = `${timestamp}.${method}.${path}.${body}`
signature = hex( HMAC-SHA256(secret, payload) )Notes that trip everyone up:
timestampis milliseconds since epoch, as a string (not ISO, not seconds).methodis uppercase:GET,POST,PUT,DELETE.pathincludes the query string exactly as it appears on the wire — that's the same string the server reads asreq.originalUrl. Include the?and params; drop nothing.bodyis the raw JSON string you send on the wire, byte-for-byte. ForGET/DELETE(no body), use an empty string.- No leading/trailing whitespace, no pretty-printing. If you stringify with indentation and then send a compact version, the signature won't match.
Worked example
Every value below is real — you can reproduce the hex digest with any HMAC library.
timestamp = "1776182400000"
method = "POST"
path = "/v2/deliveries"
body = '{"reference":"order-1","payment":{"amount":4.5,"type":"paid"}}'
secret = "00000000-0000-0000-0000-000000000000"
payload = "1776182400000.POST./v2/deliveries.{"reference":"order-1","payment":{"amount":4.5,"type":"paid"}}"
HMAC-SHA256(secret, payload)
= "7cb2...the-64-hex-digits-here..."
Headers sent on the wire:
Authorization: Key main_abcdef123456...
x-armada-timestamp: 1776182400000
x-armada-signature: 7cb2...Implementation reference
import crypto from 'node:crypto';
function sign({ apiSecret, method, path, body = '' }) {
const timestamp = Date.now().toString(); // milliseconds since epoch, as string
const payload = `${timestamp}.${method}.${path}.${body}`;
const signature = crypto
.createHmac('sha256', apiSecret)
.update(payload)
.digest('hex');
return { timestamp, signature };
}
// The path MUST include the query string exactly as sent on the wire.
const { timestamp, signature } = sign({
apiSecret: process.env.ARMADA_API_SECRET,
method: 'GET',
path: '/v2/invoices?status=paid&page=1',
});Timestamp skew
The server rejects requests where abs(now - timestamp) exceeds 30
seconds. In practice you'll hit this in three situations:
- Clock drift on the client. Sync via NTP. On a container, remember the host's clock, not the container's.
- Using seconds instead of milliseconds. A millisecond value parsed as seconds looks like a timestamp from the year 58,000+. The server rejects it as "too far in the future."
- Queueing requests. If you sign a request and then sit on it for a minute before sending, the signature expires. Always sign immediately before the HTTP call.
Debugging 401
When a request fails with 401, check these in order. Nine times out of ten it's
one of the first three.
- Is the key right? Copy it fresh from the business-app API Keys page. A trailing newline or whitespace breaks the header.
- Is the secret right? If you lost it, click Show secret on the key.
- Timestamp format. Is it milliseconds? Is it a string? Is your clock within 30 seconds?
- Canonical path. Does the signed path include the query string exactly as
sent? HTTP libraries sometimes add
?even when the query is empty — match that. - Body mismatch. Did you sign one JSON string and send another (e.g. pretty vs compact, different key ordering)? Stringify once, sign and send the same bytes.
- Secret rotation. If someone rotated the secret recently, old signed requests will fail. Grab the new secret.
When to rotate the secret
- You think it leaked. Committed to a public repo, pasted in a support ticket, sent via email. Rotate immediately.
- An employee with access to it left. Rotate as part of off-boarding.
- Periodic hygiene. Not required, but a quarterly rotation is a reasonable baseline.
Rotation is done via the business-app API Keys page — it creates a new secret and invalidates the old one atomically.