Skip to content

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[@]}"
echo

For 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/null

Pick 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/null

Expected output:

text
catalog: 200 EXAMPLE-SKU-1 ships to TX
check: 200 wouldAccept = true []
create: 201 axiomOrderId = 184019
get: 200 status = received

What just happened:

  • POST /orders/check returned wouldAccept: true with 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 /orders returned 201 with your axiomOrderId. The Idempotency-Key header and the body's idempotency_key carry the same value, which create requires. Sending the same request again returns the same 201 and the same order.
  • The order starts as received. In sandbox it moves to routed almost at once, to shipped about 30 seconds later (with a SANDBOX- tracking number), and to delivered about a minute after that.
  • The clinic quickstart-clinic-001 did not exist before. In production, an order naming a clinic external_id we 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 with 422 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.

  1. In the partner portal, open Webhooks and add an endpoint with environment sandbox. A sandbox endpoint may use http or https; a production endpoint must use https, and neither may point at a private or local address.
  2. Copy the endpoint's signing secret, which is shown once. It is a different secret from your API key.
  3. 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 queue

Place 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 ​