Appearance
Quickstart
From a sandbox key to your first order, end to end, in about ten minutes. Everything here runs in sandbox: no pharmacy sees the order, nothing ships and nothing is billed.
You need your partner slug and a partner portal login, both of which we send when your access is approved, plus Node 18 or later, Python 3.9 or later, or curl with openssl.
1. Mint a sandbox key
Sign in to the partner portal at app.axiomscript.com, open API Keys, and create a key with environment sandbox. The signing secret is shown once: copy it into your secret store straight away. If you lose it, revoke the key and mint another.
Put the three values in your environment:
bash
export AXIOM_PARTNER_SLUG="acme-health" # your slug
export AXIOM_SIGNING_SECRET="paste-the-secret-here"
export AXIOM_BASE_URL="https://api.axiomscript.com"2. Add a signing helper
Every request is signed. This helper builds the signature, sets the headers and sends the request. Copy the one for your language; the rest of the docs use it. How the signature works is explained in Authentication and signing.
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[@]}"
echoFor the curl helper, save it as axiom-request.sh and run chmod +x axiom-request.sh. It reads a request body from stdin (./axiom-request.sh POST /orders/check < order.json). openssl receives the signing key as a command-line argument, which other users of the same machine can see, so use it only on a machine you alone use, and use the Node or Python helper in your services. Keep sandbox keys as safe as production ones: they cannot touch production data, but they can manage your sandbox webhook endpoints.
3. Read your catalog
The catalog lists the products your account can order, your price, and the states each can ship to today.
mjs
import { axiom } from "./axiom-client.mjs";
const { status, body } = await axiom("GET", "/catalog");
console.log(status, body.catalog.map((p) => `${p.sku} -> ${p.availableStates.join(",")}`));py
from axiom_client import axiom
result = axiom("GET", "/catalog")
print(result["status"], [(p["sku"], p["availableStates"]) for p in result["body"]["catalog"]])bash
./axiom-request.sh GET /catalog < /dev/nullPick a SKU that is not backordered and has no unavailableUntil, and one of its availableStates. Those are the states to offer; the check in the next step confirms it for the clinic on the order.
4. Check, create and read back an order
This script runs the whole flow: it reads the catalog, checks the order (nothing is created), creates it with an idempotency key, then reads it back by your own order id. Every name and number in it is fake.
mjs
// quickstart.mjs: catalog, check, order, read back. Run with your SANDBOX key.
import { randomUUID } from "node:crypto";
import { axiom } from "./axiom-client.mjs";
// 1. What can you order, and where can it ship?
const catalog = await axiom("GET", "/catalog");
const product = catalog.body.catalog.find((p) => p.availableStates.length > 0 && !p.backordered && !p.unavailableUntil);
const state = product.availableStates[0];
console.log("catalog:", catalog.status, product.sku, "ships to", state);
// 2. Build the order. Every name and number here is fake.
const key = randomUUID(); // one key per logical order, in the header AND the body
const order = {
partner: process.env.AXIOM_PARTNER_SLUG,
partner_order_id: `QS-${Date.now()}`,
idempotency_key: key,
shipping_method: "standard",
patient: {
first_name: "Jane",
last_name: "Doe",
dob: "1980-01-01",
sex: "F",
phone: "406-555-0142",
address: { line1: "123 Main St", city: "Springfield", state, zip: "62701" },
},
physician: {
name: "Dr. Alex Example",
first_name: "Alex",
last_name: "Example",
credential: "MD",
npi: "1234567893",
phone: "406-555-0100",
address: { line1: "500 Clinic Way", city: "Springfield", state, zip: "62701" },
},
clinic: { external_id: "quickstart-clinic-001", name: "Quickstart Example Clinic" },
items: [{ sku: product.sku, strength: product.strength ?? "n/a", quantity: 1, directions: "Use as directed.", refills: 0 }],
};
// 3. Check it before you take payment. Nothing is created.
const check = await axiom("POST", "/orders/check", order);
console.log("check:", check.status, "wouldAccept =", check.body.wouldAccept, JSON.stringify(check.body.order));
if (!check.body.wouldAccept) process.exit(1);
// 4. Create it. Retrying with the same key returns the same order, never a second one.
const created = await axiom("POST", "/orders", order, { idempotencyKey: key });
console.log("create:", created.status, "axiomOrderId =", created.body.axiomOrderId);
// 5. Read it back by your own id.
const fetched = await axiom("GET", `/orders/${encodeURIComponent(order.partner_order_id)}`);
console.log("get:", fetched.status, "status =", fetched.body.order.status);py
# quickstart.py: catalog, check, order, read back. Run with your SANDBOX key.
import os
import sys
import time
import urllib.parse
import uuid
from axiom_client import axiom
# 1. What can you order, and where can it ship?
catalog = axiom("GET", "/catalog")
product = next(p for p in catalog["body"]["catalog"]
if p["availableStates"] and not p["backordered"] and not p["unavailableUntil"])
state = product["availableStates"][0]
print("catalog:", catalog["status"], product["sku"], "ships to", state)
# 2. Build the order. Every name and number here is fake.
key = str(uuid.uuid4()) # one key per logical order, in the header AND the body
order = {
"partner": os.environ["AXIOM_PARTNER_SLUG"],
"partner_order_id": f"QS-{int(time.time() * 1000)}",
"idempotency_key": key,
"shipping_method": "standard",
"patient": {
"first_name": "Jane",
"last_name": "Doe",
"dob": "1980-01-01",
"sex": "F",
"phone": "406-555-0142",
"address": {"line1": "123 Main St", "city": "Springfield", "state": state, "zip": "62701"},
},
"physician": {
"name": "Dr. Alex Example",
"first_name": "Alex",
"last_name": "Example",
"credential": "MD",
"npi": "1234567893",
"phone": "406-555-0100",
"address": {"line1": "500 Clinic Way", "city": "Springfield", "state": state, "zip": "62701"},
},
"clinic": {"external_id": "quickstart-clinic-001", "name": "Quickstart Example Clinic"},
"items": [{"sku": product["sku"], "strength": product.get("strength") or "n/a", "quantity": 1,
"directions": "Use as directed.", "refills": 0}],
}
# 3. Check it before you take payment. Nothing is created.
check = axiom("POST", "/orders/check", order)
print("check:", check["status"], "wouldAccept =", check["body"]["wouldAccept"], check["body"]["order"])
if not check["body"]["wouldAccept"]:
sys.exit(1)
# 4. Create it. Retrying with the same key returns the same order, never a second one.
created = axiom("POST", "/orders", order, idempotency_key=key)
print("create:", created["status"], "axiomOrderId =", created["body"]["axiomOrderId"])
# 5. Read it back by your own id.
fetched = axiom("GET", "/orders/" + urllib.parse.quote(order["partner_order_id"], safe=""))
print("get:", fetched["status"], "status =", fetched["body"]["order"]["status"])sh
#!/usr/bin/env bash
# quickstart.sh: check, order, read back with curl. Run with your SANDBOX key.
# Set SKU and STATE from your catalog first: ./axiom-request.sh GET /catalog
# (a SKU that is not backordered, has no unavailableUntil, and lists STATE in availableStates)
set -euo pipefail
cd "$(dirname "$0")"
SKU="${SKU:?set SKU to a sku from your catalog}"
STATE="${STATE:?set STATE to one of the availableStates of that sku}"
KEY="$(openssl rand -hex 16)" # one key per logical order, in the header AND the body
ORDER_ID="QS-$(date +%s)"
# Every name and number here is fake.
BODY=$(cat <<JSON
{"partner":"${AXIOM_PARTNER_SLUG}","partner_order_id":"${ORDER_ID}","idempotency_key":"${KEY}",
"shipping_method":"standard",
"patient":{"first_name":"Jane","last_name":"Doe","dob":"1980-01-01","sex":"F","phone":"406-555-0142",
"address":{"line1":"123 Main St","city":"Springfield","state":"${STATE}","zip":"62701"}},
"physician":{"name":"Dr. Alex Example","first_name":"Alex","last_name":"Example","credential":"MD",
"npi":"1234567893","phone":"406-555-0100",
"address":{"line1":"500 Clinic Way","city":"Springfield","state":"${STATE}","zip":"62701"}},
"clinic":{"external_id":"quickstart-clinic-001","name":"Quickstart Example Clinic"},
"items":[{"sku":"${SKU}","strength":"n/a","quantity":1,"directions":"Use as directed.","refills":0}]}
JSON
)
printf '%s' "$BODY" | ./axiom-request.sh POST /orders/check # nothing is created
printf '%s' "$BODY" | ./axiom-request.sh POST /orders "$KEY" # 201, or the same 201 again on a retry
./axiom-request.sh GET "/orders/${ORDER_ID}" < /dev/nullExpected output:
text
catalog: 200 EXAMPLE-SKU-1 ships to TX
check: 200 wouldAccept = true []
create: 201 axiomOrderId = 184019
get: 200 status = receivedWhat just happened:
POST /orders/checkreturnedwouldAccept: truewith no order-level reasons. Had anything been wrong (a missing prescriber field, a state the product cannot ship to), it would have listed every reason, in the same codes order create uses.POST /ordersreturned201with youraxiomOrderId. TheIdempotency-Keyheader and the body'sidempotency_keycarry the same value, which create requires. Sending the same request again returns the same201and the same order.- The order starts as
received. In sandbox it moves toroutedalmost at once, toshippedabout 30 seconds later (with aSANDBOX-tracking number), and todeliveredabout a minute after that. - The clinic
quickstart-clinic-001did not exist before. In production, an order naming a clinicexternal_idwe have not seen creates that clinic for you. A sandbox order never creates a clinic. If your account requires a signed agreement per clinic (an attestation you send, or Axiom's e-signature), the clinic needs it before its first order in production, or its orders are refused with422 agreement_unsigned. Sandbox enforces this by default only for partner-attested accounts; see Sandbox testing. See Clinics and agreements.
5. Receive the webhooks
Instead of polling, register an endpoint and we push each status change to it.
- In the partner portal, open Webhooks and add an endpoint with environment sandbox. A sandbox endpoint may use
httporhttps; a production endpoint must usehttps, and neither may point at a private or local address. - Copy the endpoint's signing secret, which is shown once. It is a different secret from your API key.
- Verify every delivery before you trust it:
mjs
// verify-webhook.mjs: check that a webhook delivery came from Axiom, before you parse it.
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_SKEW_SECONDS = 300;
/**
* rawBody: the request body exactly as received (a Buffer or string), NOT re-serialized JSON.
* headers: the request headers, lower-cased (as Node and Express give them).
* secret: the endpoint's signing secret, shown once when you created the endpoint.
*/
export function isFromAxiom(rawBody, headers, secret) {
const timestamp = String(headers["x-axiom-timestamp"] ?? "");
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const received = String(headers["x-axiom-signature"] ?? "").replace(/^sha256=/, "");
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(received, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
// With Express, read the raw bytes for this route only:
//
// app.post("/webhooks/axiom", express.raw({ type: "application/json" }), async (req, res) => {
// if (!isFromAxiom(req.body, req.headers, process.env.AXIOM_WEBHOOK_SECRET)) return res.sendStatus(401);
// const event = JSON.parse(req.body.toString("utf8"));
// await saveEvent(event); // store or enqueue durably FIRST: a 2xx means we will not resend it
// res.sendStatus(200); // then acknowledge, within 20 seconds
// handle(event); // slow work after
// });py
# verify_webhook.py: check that a webhook delivery came from Axiom, before you parse it.
# Python 3.9 or later, standard library only.
import hashlib
import hmac
import time
MAX_SKEW_SECONDS = 300
def is_from_axiom(raw_body: bytes, headers, secret: str) -> bool:
"""raw_body: the request body exactly as received. headers: any case-insensitive mapping."""
timestamp = headers.get("X-Axiom-Timestamp", "")
if not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(int(time.time()) - int(timestamp)) > MAX_SKEW_SECONDS:
return False
received = headers.get("X-Axiom-Signature", "").removeprefix("sha256=")
expected = hmac.new(secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))
# With Flask:
#
# @app.post("/webhooks/axiom")
# def axiom_webhook():
# if not is_from_axiom(request.get_data(), request.headers, os.environ["AXIOM_WEBHOOK_SECRET"]):
# abort(401)
# event = request.get_json()
# save_event(event) # store or enqueue durably FIRST: a 2xx means we will not resend it
# return "", 200 # then acknowledge, within 20 seconds; do slow work from the queuePlace another sandbox order and you will receive order.status_changed for routed, shipped and delivered. The portal's Deliveries page shows each delivery with its attempt count and latest response code, and lets you replay one. More in Webhooks.
Next
- Handle the refusals you will meet in production: Orders lifecycle and Errors and rate limits.
- Enroll clinics properly if your account uses signed agreements: Clinics and agreements.
- Work through the Go-live checklist.

