Skip to content

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}
PartWhat to use
METHODThe HTTP method in upper case: GET, POST, PUT, PATCH, DELETE.
pathThe 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.
timestampThe current Unix time in whole seconds, as digits only.
bodyThe 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:

HeaderValue
X-Axiom-TimestampThe timestamp you signed.
X-Axiom-Signaturesha256= followed by the hex signature. The prefix is optional; we recommend sending it.
Content-Typeapplication/json on any request with a JSON body.
Idempotency-KeyOn 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[@]}"
echo

Rules 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=10 and GET /orders?limit=20 have 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).

StatusCodeWhat it means and what to do
401hmac_signature_missingNo X-Axiom-Signature header.
401hmac_timestamp_missingNo X-Axiom-Timestamp header.
401hmac_timestamp_invalidThe timestamp is not a whole number of seconds.
401hmac_timestamp_skewThe timestamp is more than 300 seconds from our clock. Fix your clock; sign right before sending.
401hmac_partner_mismatchX-Axiom-Partner-Slug does not match the slug in the URL.
401hmac_partner_unknownNo account has that slug. Check the URL.
401hmac_invalidThe 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.
403production_not_enabledA production key was used before Axiom enabled production for your account. Use your sandbox key meanwhile.
423partner_inactiveYour account is deactivated. Contact Axiom.
423tenant_suspendedYour 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 ​

  1. 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.
  2. Check the path starts with /api/inbound/ (or /api/partner/ for webhook endpoints) and includes the query string.
  3. Check the method is upper case and there is a dot between every part, including the trailing dot on a request with no body.
  4. Check you are using the secret of an active key in the environment you intend.
  5. Every response carries an X-Request-Id header. Quote it when you ask us to look at a request.