Armada Armada API v2

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.

Field
Type
Description
Authorization required
string
The literal string Key, a space, then the API key value (starts with main_). Example: Authorization: Key main_d7b82c8f...
x-armada-timestamp required
string (ms epoch)
The current time in milliseconds since epoch, as a decimal string. Not ISO-8601. Must be within 30 seconds of Armada server time.
x-armada-signature required
string (hex)
The hex-encoded HMAC-SHA256 of the canonical payload, using the API secret as the key.
Content-Type optional
string
Always application/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:

  • timestamp is milliseconds since epoch, as a string (not ISO, not seconds).
  • method is uppercase: GET, POST, PUT, DELETE.
  • path includes the query string exactly as it appears on the wire — that's the same string the server reads as req.originalUrl. Include the ? and params; drop nothing.
  • body is the raw JSON string you send on the wire, byte-for-byte. For GET/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.

  1. Is the key right? Copy it fresh from the business-app API Keys page. A trailing newline or whitespace breaks the header.
  2. Is the secret right? If you lost it, click Show secret on the key.
  3. Timestamp format. Is it milliseconds? Is it a string? Is your clock within 30 seconds?
  4. 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.
  5. 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.
  6. 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.