Appearance
Authentication and signing
Summary: sign every request with HMAC-SHA256 over METHOD.path.timestamp.body using your key's secret, and send the timestamp and signature in two headers. There are no bearer tokens, sessions or API key headers.
The signature
Build this string, with literal dots between the parts:
text
{METHOD}.{path}.{timestamp}.{body}| Part | What to use |
|---|---|
METHOD | The HTTP method in upper case: GET, POST, PUT, PATCH, DELETE. |
path | The request path exactly as you send it, with no scheme or host and including the query string, for example /api/inbound/acme-health/orders?status=shipped&limit=50. |
timestamp | The current Unix time in whole seconds, as digits only. |
body | The exact bytes of the request body. For a request with no body (every GET), it is empty and the string ends with a dot. |
Compute HMAC-SHA256(secret, string) and render it as lowercase hex. Then send:
| Header | Value |
|---|---|
X-Axiom-Timestamp | The timestamp you signed. |
X-Axiom-Signature | sha256= followed by the hex signature. The prefix is optional; we recommend sending it. |
Content-Type | application/json on any request with a JSON body. |
Idempotency-Key | On requests that create or change something. See Idempotency. |
You may also send X-Axiom-Partner-Slug. It is optional, and if you send it, it must equal the slug in the URL or the request is refused with 401 hmac_partner_mismatch.
mjs
// axiom-client.mjs: sign and send one request to the AXIOM Connect partner API.
// Node 18 or later, no dependencies.
import { createHmac } from "node:crypto";
const BASE_URL = process.env.AXIOM_BASE_URL ?? "https://api.axiomscript.com";
const SLUG = process.env.AXIOM_PARTNER_SLUG; // e.g. "acme-health"
const SECRET = process.env.AXIOM_SIGNING_SECRET; // the secret shown once when your key was created
/**
* method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
* path: everything after /api/inbound/{slug}, including any query string, e.g. "/orders?limit=10".
* A path that starts with /api/ is sent as-is (the webhook endpoints live under /api/partner/).
*/
export async function axiom(method, path, body, { idempotencyKey } = {}) {
const fullPath = path.startsWith("/api/") ? path : `/api/inbound/${SLUG}${path}`;
const raw = body === undefined ? "" : JSON.stringify(body); // sign exactly the bytes you send
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", SECRET)
.update(`${method}.${fullPath}.${timestamp}.${raw}`)
.digest("hex");
const headers = {
"X-Axiom-Timestamp": timestamp,
"X-Axiom-Signature": `sha256=${signature}`,
};
if (raw) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(BASE_URL + fullPath, { method, headers, body: raw || undefined });
const text = await res.text();
return {
status: res.status,
requestId: res.headers.get("x-request-id"),
body: text ? JSON.parse(text) : null,
};
}py
# axiom_client.py: sign and send one request to the AXIOM Connect partner API.
# Python 3.9 or later, standard library only.
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
BASE_URL = os.environ.get("AXIOM_BASE_URL", "https://api.axiomscript.com")
SLUG = os.environ["AXIOM_PARTNER_SLUG"] # e.g. "acme-health"
SECRET = os.environ["AXIOM_SIGNING_SECRET"] # the secret shown once when your key was created
def axiom(method, path, body=None, idempotency_key=None):
"""path is everything after /api/inbound/{slug}, including any query string.
A path that starts with /api/ is sent as-is (the webhook endpoints live under /api/partner/)."""
full_path = path if path.startswith("/api/") else f"/api/inbound/{SLUG}{path}"
raw = b"" if body is None else json.dumps(body).encode("utf-8") # sign exactly these bytes
timestamp = str(int(time.time()))
message = f"{method}.{full_path}.{timestamp}.".encode("utf-8") + raw
signature = hmac.new(SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
headers = {
"X-Axiom-Timestamp": timestamp,
"X-Axiom-Signature": f"sha256={signature}",
}
if raw:
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(BASE_URL + full_path, data=raw or None, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=30) as response:
status, text, request_id = response.status, response.read(), response.headers.get("X-Request-Id")
except urllib.error.HTTPError as error: # 4xx and 5xx still carry a JSON body worth reading
status, text, request_id = error.code, error.read(), error.headers.get("X-Request-Id")
return {"status": status, "request_id": request_id, "body": json.loads(text) if text else None}sh
#!/usr/bin/env bash
# axiom-request.sh METHOD PATH [IDEMPOTENCY_KEY] < body.json
# Reads the JSON body (if any) from stdin, so it never appears in a command line. openssl takes the
# signing key as a command-line argument, which other users of the same machine can see in the
# process list: run this only on a machine you alone use, and use the Node or Python helper in services.
# Signs and sends one request with curl and openssl. PATH is everything after
# /api/inbound/{slug}, including any query string. A PATH that starts with /api/
# is sent as-is (the webhook endpoints live under /api/partner/).
set -euo pipefail
BASE_URL="${AXIOM_BASE_URL:-https://api.axiomscript.com}"
METHOD="$1"
case "$2" in
/api/*) FULL_PATH="$2" ;;
*) FULL_PATH="/api/inbound/${AXIOM_PARTNER_SLUG}$2" ;;
esac
KEY="${3:-}"
BODY=""
if [ ! -t 0 ]; then BODY="$(cat)"; fi
TIMESTAMP="$(date +%s)"
SIGNATURE="$(printf '%s' "${METHOD}.${FULL_PATH}.${TIMESTAMP}.${BODY}" \
| openssl dgst -sha256 -hmac "$AXIOM_SIGNING_SECRET" -hex | awk '{print $NF}')"
ARGS=(-sS -X "$METHOD" "${BASE_URL}${FULL_PATH}"
-H "X-Axiom-Timestamp: ${TIMESTAMP}"
-H "X-Axiom-Signature: sha256=${SIGNATURE}")
if [ -n "$BODY" ]; then ARGS+=(-H "Content-Type: application/json" --data-binary @-); fi
if [ -n "$KEY" ]; then ARGS+=(-H "Idempotency-Key: ${KEY}"); fi
# The body goes to curl on stdin too, never as an argument.
printf '%s' "$BODY" | curl "${ARGS[@]}"
echoRules that catch people out
- Sign the bytes you send. Serialize the body once, sign that string, and send that same string. Re-serializing after signing (a client library that reformats JSON, a proxy that re-encodes it) changes the bytes and breaks the signature.
- The query string is signed.
GET /orders?limit=10andGET /orders?limit=20have different signatures. Build the path with its query first, then sign it, then send exactly that path. - Your clock must be right. The timestamp must be within 300 seconds of our clock, in either direction, or the request is refused with
401 hmac_timestamp_skew. Keep your servers on NTP. Sign immediately before sending, and sign again for every retry. - The method and path are part of the signature. A signature made for one endpoint does not work on another, so a captured request cannot be replayed against a different endpoint. Always sign this way; the older format without them is accepted only for accounts Axiom is migrating.
Keys
You create and revoke keys yourself on the API Keys page of your partner portal (app.axiomscript.com/partner/keys). Creating or revoking a key needs a partner admin login and a recent sign-in.
- Each key belongs to one environment, sandbox or production. The key that signed a request decides which environment the request runs in. A sandbox key can only ever create sandbox orders.
- A sandbox key never touches production data. It cannot change a real clinic (its details, bank account or identity) or a production webhook endpoint. Protect it anyway: it manages your sandbox endpoints.
- A production key does nothing until Axiom enables production for your account. Until then it is refused with
403 production_not_enabled. See the Go-live checklist. - The secret is shown once, when you create the key. We store it encrypted and cannot show it again. If it is lost, revoke the key and create a new one.
- You can hold several active keys at once. To rotate without downtime, create a new key, deploy it, confirm traffic is signed with it, then revoke the old one. A revoked key stops working immediately.
Errors
Signature problems are answered with a JSON body of the form {"error": "<code>"}. Two things are checked before the signature: the rate limit (429) and whether the body is valid JSON within size (400, 413).
| Status | Code | What it means and what to do |
|---|---|---|
| 401 | hmac_signature_missing | No X-Axiom-Signature header. |
| 401 | hmac_timestamp_missing | No X-Axiom-Timestamp header. |
| 401 | hmac_timestamp_invalid | The timestamp is not a whole number of seconds. |
| 401 | hmac_timestamp_skew | The timestamp is more than 300 seconds from our clock. Fix your clock; sign right before sending. |
| 401 | hmac_partner_mismatch | X-Axiom-Partner-Slug does not match the slug in the URL. |
| 401 | hmac_partner_unknown | No account has that slug. Check the URL. |
| 401 | hmac_invalid | The signature does not match any active key. Check the secret, the exact path with its query string, the exact body bytes, and that the key is not revoked. |
| 403 | production_not_enabled | A production key was used before Axiom enabled production for your account. Use your sandbox key meanwhile. |
| 423 | partner_inactive | Your account is deactivated. Contact Axiom. |
| 423 | tenant_suspended | Your account is suspended. Contact Axiom. |
The 403 and 423 answers are only given to a correctly signed request, so these API answers do not tell anyone without your secret whether your account is active, suspended or enabled for production. (Whether a slug exists at all is visible: an unknown slug answers hmac_partner_unknown.)
Debugging a 401 hmac_invalid
- In sandbox, with fake data, compare the exact string you signed with the exact path and body you sent. They must match byte for byte. Do not log production request bodies: they carry patient details.
- Check the path starts with
/api/inbound/(or/api/partner/for webhook endpoints) and includes the query string. - Check the method is upper case and there is a dot between every part, including the trailing dot on a request with no body.
- Check you are using the secret of an active key in the environment you intend.
- Every response carries an
X-Request-Idheader. Quote it when you ask us to look at a request.

