Appearance
Webhooks
Summary: register an HTTPS endpoint per environment, subscribe it to the events you need, and we POST a signed JSON event to it whenever something happens. Verify the signature against the raw body, answer any 2xx within 20 seconds, and do the slow work afterwards. Failed deliveries are retried for about an hour and a quarter, and you can replay any of them from the partner portal.
Delivery at a glance
Loading diagram...
Register an endpoint
Use the Webhooks page of the partner portal, or the API at /api/partner/{slug}/webhooks, signed exactly like every other request.
| Request | Does |
|---|---|
GET /api/partner/{slug}/webhooks | Lists your endpoints, newest first. |
POST /api/partner/{slug}/webhooks | Creates one. Body: url (required, up to 2048 characters), environment (sandbox or production, default: the environment of the key that signs the request; a key can only create endpoints in its own environment, otherwise 403 environment_mismatch), eventTypes (up to 10 event names; empty means every event), description (up to 200 characters). Answers 201 with the endpoint and its secret. |
PATCH /api/partner/{slug}/webhooks/{id} | Changes url, eventTypes, description or active. The environment cannot change: delete and re-create instead. |
DELETE /api/partner/{slug}/webhooks/{id} | Deletes it. |
POST /api/partner/{slug}/webhooks/{id}/rotate-secret | Issues a new secret and returns it. The old secret stops working at once. |
These requests need no Idempotency-Key. Registering a URL you already have is refused with 409 endpoint_url_already_registered.
mjs
// register-webhook.mjs: register a webhook endpoint through the API (the portal's Webhooks page does
// the same). The secret in the response is shown once: store it, then use it to verify deliveries.
import { axiom } from "./axiom-client.mjs";
const base = `/api/partner/${process.env.AXIOM_PARTNER_SLUG}/webhooks`;
const created = await axiom("POST", base, {
url: "https://partner.example.com/webhooks/axiom",
environment: "sandbox", // or "production"; it cannot be changed later
eventTypes: ["order.status_changed", "order.failed"], // [] means every event
description: "Order updates",
});
console.log(created.status, created.body.id, created.body.secretPreview);
// created.body.secret holds the full signing secret. Save it now; it is never shown again.
const list = await axiom("GET", base);
console.log(list.body.webhooks.map((w) => `${w.id} ${w.environment} ${w.url} active=${w.active}`));The secret is shown once, on create and on rotate. Each endpoint has its own secret, separate from your API keys. Afterwards only a short preview (secretPreview) is shown.
URL rules. A production endpoint must be https. A sandbox endpoint may also be http. Neither may point at a local or private address (for example localhost, 10.x, 192.168.x, link-local or carrier-grade NAT ranges), and the URL may not contain spaces or control characters. We do not follow redirects: a 3xx answer counts as a failure.
Environments. An event goes to the active, subscribed endpoints of the environment it happened in, so a sandbox order's events go to your sandbox endpoints. If Axiom set up a single webhook URL for your account before self-service endpoints existed, it is your production receiver: production events with no production endpoint registered still go to it, and sandbox events never do. Sandbox events need a sandbox endpoint, and a sandbox endpoint cannot use that single URL. Every delivery is signed with the secret of an endpoint in its own environment.
Queued deliveries. Deliveries that are already queued keep the URL they were queued for, and each attempt is signed with the secret of the active endpoint that has that URL when it is sent (or, if none does, with a legacy account secret if Axiom set one up). So after you rotate a secret, retries arrive signed with the new one; after you pause, delete or re-point an endpoint, queued deliveries can still arrive at the old URL.
Each key manages its own environment. A sandbox key lists, changes, rotates and deletes only sandbox endpoints, and a production key only production ones; an endpoint in the other environment answers 404. The partner portal manages both.
Events
| Event | Sent when |
|---|---|
order.status_changed | An order moves to routed, in_production, ready_to_pack, ready_to_ship, shipped, delivered or cancelled, and when a shipped order's tracking changes. Not sent for an order Axiom creates as a re-send of one of yours. |
order.failed | We could not route an accepted order or send it to the pharmacy. (An order the pharmacy itself reports as failed sends no event: re-read your recent orders regularly, whatever their status.) |
product.availability_changed | A SKU in your catalog becomes orderable or stops being orderable (out of stock, back, switched off, held until a date). |
ticket.message_added | A message was added to one of your support tickets, by Axiom or by your side (lastMessageFrom says which). Sent when ticketing is on for your account. |
ticket.status_changed | One of your tickets changed status. |
invoice.issued, invoice.paid, invoice.voided | An invoice addressed to your account was issued, paid or voided. Production only. |
notice.published, notice.withdrawn | Axiom published or withdrew an announcement for you. |
Field-by-field payloads for the order, availability and notice events are in the webhook events reference. Every payload carries event, so one endpoint can receive several kinds.
order.status_changed:
json
{
"event": "order.status_changed",
"orderId": 184019,
"partnerOrderId": "ACME-0001",
"oldStatus": "routed",
"newStatus": "shipped",
"pharmacy": { "id": 12, "name": "Example Pharmacy" },
"trackingNumber": "1Z999AA10123456784",
"carrier": "UPS",
"shipments": [{ "axiomOrderId": 184019, "trackingNumber": "1Z999AA10123456784", "carrier": "UPS" }],
"shippedAt": "2026-10-26T15:02:11.000Z",
"deliveredAt": null,
"occurredAt": "2026-10-26T15:02:12.418Z"
}order.failed carries orderId, partnerOrderId, code (no_eligible_pharmacy, hard_rule_conflict, routing_failed or pharmacy_dispatch_failed), message, retryable (always false), pharmacy and occurredAt. It does not mean the order is safe to resubmit: read the order's failure first, as the Orders lifecycle guide explains.
The ticket events carry ticketId, status (open, in_progress or resolved), oldStatus on a status change, partnerOrderId, lastMessageFrom (axiom, partner, or null when the ticket has no reply yet), lastMessageAt (also null then), resolvedAt, updatedAt and occurredAt. They never include the message text: read the ticket to get it.
The invoice events carry invoiceNumber, status, totalCents, issuedAt, dueAt, paidAt and occurredAt.
Verify every delivery
Each delivery is a POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | axiom-connect-webhook/1.0 |
X-Axiom-Timestamp | Unix seconds when this attempt was signed. |
X-Axiom-Signature | sha256= and the lowercase hex HMAC-SHA256 of {timestamp}.{rawBody}, keyed with the endpoint's secret. |
Unlike your requests to us, the webhook signature covers only the timestamp and the body. Verify it against the raw body before parsing, compare in constant time, and refuse a timestamp more than 5 minutes from your clock so a captured delivery cannot be replayed later.
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 queueThese functions were tested against the signer our delivery service uses: they accept its deliveries and reject a changed body, a wrong secret and a stale timestamp.
Retries, ordering and duplicates
- Answer fast, but only once you have it. We wait up to 20 seconds, and any
2xxmeans "received": we will not send that delivery again. Store or enqueue the event durably first, answer2xx, then do the slow work. - Retried: a network error, a timeout, any
5xx, and429. We try 5 times in all, waiting about 30 seconds, 2 minutes, 10 minutes and 1 hour between attempts. Each attempt is signed again with a fresh timestamp; the body is the same. - Not retried: any other
4xx, and any redirect. These mark the delivery failed straight away, so return4xxonly when you never want that event. - No ordering guarantee. Deliveries run in parallel and retry independently, so
deliveredcan arrive before a delayedshipped. UseoccurredAtand the status itself, and treat the latest status fromGET /orders/{id}as the truth when in doubt. - Expect duplicates. A delivery can arrive twice (for example after a replay). Make handling idempotent, for example by storing
orderIdwithnewStatusandoccurredAt. - Webhooks are a nudge, not the record. If your endpoint was down, re-read with
GET /orders/{id}every order from your reconciliation window (for example, created in the last 60 days), whatever its status: afailedorder can go back torouted, a pharmacy can resume acancelledone, and Axiom can correct adeliveredone. Note thatGET /orders?since=filters on when an order was created, not when it last changed, so it does not find older orders that moved during the outage.
Delivery log and replay
The portal's Deliveries page lists every delivery to your endpoints with its event type, status (pending, delivered, failed or dead), attempt count, last response code and timestamps. From there you can replay a delivery that did not get through: it is sent again with the same body and a fresh signature. A delivery that already succeeded is not replayed.
Checklist
- [ ] One endpoint per environment, production on
https. - [ ] Production endpoint subscribed to at least
order.status_changed, and we recommendorder.failed. - [ ] Signature verified on the raw body, timestamp checked, constant-time compare.
- [ ] Event stored or enqueued durably, then
2xxwithin 20 seconds; slow work after. - [ ] Handling is idempotent and does not depend on arrival order.
- [ ] A daily reconcile re-reads, by id, every order from a recent window, whatever its status.

