Skip to content

8. Webhook events ​

What Axiom sends to your registered endpoint. Each event is a signed POST; answer any 2xx to acknowledge it.

Read first: Webhooks.

Order status changed (delivers the tracking number) ​

EVENTorder.status_changed

POSTed to each active endpoint you register (Developer Portal -> Webhooks, or the webhooks API).

SIGNATURE. Every delivery carries two headers: x-axiom-timestamp (unix seconds) and x-axiom-signature: sha256=<hex>. The signature is HMAC-SHA256(yourEndpointSecret, "{x-axiom-timestamp}.{rawBody}"), hex-encoded. Note the signed string is the timestamp, a literal dot, then the raw request body. Signing the body alone will NOT match. The secret is the 64-char hex value shown once when you create or rotate the endpoint: each attempt is signed with the secret of the active endpoint registered for the delivery's URL at the moment it is sent, so after a rotation, retries carry the new secret.

RETRIES. Respond 2xx to acknowledge. A network error, any 5xx, or a 429 is retried: we make up to 5 attempts in total, scheduling the next one 30s, then 2m, then 10m, then 1h after the previous failure. After the 5th failed attempt the delivery is marked dead and we stop. Any other 4xx is treated as a permanent rejection and is NOT retried at all. The Deliveries tab shows each delivery's status, latest response code and attempt count, and a failed or dead delivery can be replayed from there.

Payload ​

event"order.status_changed"required

 

orderIdintegerrequired

The Axiom order id.

partnerOrderIdstringrequired

Your id for the order.

oldStatusstringrequired

Where an order is. The values are listed in the order an order usually moves through them, but the movement is not strictly forward and not every step is reported: an order can jump from routed to a later state, can go back to received when a pharmacy declines it and we reroute, and a failed order can return to routed when Axiom retries or reconciles it. Never require each step to appear, and treat any value you do not recognise as still in progress. In sandbox, routing, shipping and delivery are simulated: no pharmacy is assigned and the tracking number is a generated SANDBOX- value. received: accepted, and waiting to be routed or rerouted. A SPLIT order (one no single pharmacy can fill, fulfilled as one child order per pharmacy) stays received while its children are in progress, so a child can read shipped while the parent still reads received. The parent moves to shipped once every child is settled: shipped or delivered, or failed or cancelled with its lines credited. So a split parent reading shipped does NOT mean every line shipped; shipments and the billing feed say what did. routed: a pharmacy is assigned. Sending it to them may still be pending, held, or done, so this alone does not mean the pharmacy has accepted it. A dispatch problem we are holding for a person to resolve leaves the order routed, with no failure webhook. in_production: the pharmacy is working on it, which includes their data entry, clarification and queueing as well as compounding. ready_to_pack, ready_to_ship: packing and shipping preparation. Often inferred from a later report rather than reported on their own. shipped: shipment reported by the pharmacy or the carrier, or recorded by Axiom staff. trackingNumber and carrier are normally set from here. An order shipping in more than one box reads shipped, and shipments lists the tracking numbers currently attached to it and its split children. A RE-SHIP is a new order rather than a second tracking number on the original, so the two are tied together by reship on the order itself: the re-ship reports replaces, the original reports replacedBy. Read it from GET .../orders/{partnerOrderId} or from the order list. The order.status_changed webhook does NOT carry it today, so poll the new order once when you see it, or read the link off the list you already poll. delivered: delivery reported by the carrier or the pharmacy, or recorded by Axiom staff. Simulated in sandbox. cancelled: cancelled by you, by Axiom, or by the pharmacy. This can happen after production has started. Treat it as final; in rare cases a pharmacy reverses its own void and the order resumes, with a further order.status_changed. failed: Axiom recorded a routing, dispatch or pharmacy-reported failure. A dispatch failure can be ambiguous (the pharmacy may still have received the prescription), which is why Axiom sometimes reconciles a failed order back to routed. Routing and dispatch failures that set failed send the order.failed webhook; a failure the PHARMACY reports sends no webhook today, so it shows only when you read the order or the order list. failed is never the newStatus of an order.status_changed event, but can be the oldStatus of a later one. You can cancel an order with POST .../orders/{partnerOrderId}/cancel only while it is received or routed and it has not been sent to the pharmacy (no pharmacy order number recorded and no send on record, including one the pharmacy accepted without returning a number) or, for a split order, none of its children has. A split order's children cannot be cancelled on their own. Otherwise the answer is 409 order_not_cancellable; when the order or a child was already sent, the body carries reason (dispatched or child_dispatched) and a plain-words message. One of: received, routed, in_production, ready_to_pack, ready_to_ship, shipped, delivered, cancelled, failed.

newStatusstringrequired

Where an order is. The values are listed in the order an order usually moves through them, but the movement is not strictly forward and not every step is reported: an order can jump from routed to a later state, can go back to received when a pharmacy declines it and we reroute, and a failed order can return to routed when Axiom retries or reconciles it. Never require each step to appear, and treat any value you do not recognise as still in progress. In sandbox, routing, shipping and delivery are simulated: no pharmacy is assigned and the tracking number is a generated SANDBOX- value. received: accepted, and waiting to be routed or rerouted. A SPLIT order (one no single pharmacy can fill, fulfilled as one child order per pharmacy) stays received while its children are in progress, so a child can read shipped while the parent still reads received. The parent moves to shipped once every child is settled: shipped or delivered, or failed or cancelled with its lines credited. So a split parent reading shipped does NOT mean every line shipped; shipments and the billing feed say what did. routed: a pharmacy is assigned. Sending it to them may still be pending, held, or done, so this alone does not mean the pharmacy has accepted it. A dispatch problem we are holding for a person to resolve leaves the order routed, with no failure webhook. in_production: the pharmacy is working on it, which includes their data entry, clarification and queueing as well as compounding. ready_to_pack, ready_to_ship: packing and shipping preparation. Often inferred from a later report rather than reported on their own. shipped: shipment reported by the pharmacy or the carrier, or recorded by Axiom staff. trackingNumber and carrier are normally set from here. An order shipping in more than one box reads shipped, and shipments lists the tracking numbers currently attached to it and its split children. A RE-SHIP is a new order rather than a second tracking number on the original, so the two are tied together by reship on the order itself: the re-ship reports replaces, the original reports replacedBy. Read it from GET .../orders/{partnerOrderId} or from the order list. The order.status_changed webhook does NOT carry it today, so poll the new order once when you see it, or read the link off the list you already poll. delivered: delivery reported by the carrier or the pharmacy, or recorded by Axiom staff. Simulated in sandbox. cancelled: cancelled by you, by Axiom, or by the pharmacy. This can happen after production has started. Treat it as final; in rare cases a pharmacy reverses its own void and the order resumes, with a further order.status_changed. failed: Axiom recorded a routing, dispatch or pharmacy-reported failure. A dispatch failure can be ambiguous (the pharmacy may still have received the prescription), which is why Axiom sometimes reconciles a failed order back to routed. Routing and dispatch failures that set failed send the order.failed webhook; a failure the PHARMACY reports sends no webhook today, so it shows only when you read the order or the order list. failed is never the newStatus of an order.status_changed event, but can be the oldStatus of a later one. You can cancel an order with POST .../orders/{partnerOrderId}/cancel only while it is received or routed and it has not been sent to the pharmacy (no pharmacy order number recorded and no send on record, including one the pharmacy accepted without returning a number) or, for a split order, none of its children has. A split order's children cannot be cancelled on their own. Otherwise the answer is 409 order_not_cancellable; when the order or a child was already sent, the body carries reason (dispatched or child_dispatched) and a plain-words message. One of: received, routed, in_production, ready_to_pack, ready_to_ship, shipped, delivered, cancelled, failed.

pharmacyobject | nullrequired

The pharmacy this order was routed to (the fulfiller). Null until the order routes. Axiom is the routing destination, not the fulfiller, so model this as a separate fulfilling pharmacy on your side, never as Axiom.

pharmacy.idinteger | nullrequired

Axiom's pharmacy id. NULL when a MARKETPLACE fulfilled the order and chose the pharmacy on its own side, in which case there is no Axiom pharmacy row to reference and only the name is meaningful.

pharmacy.namestringrequired

Pharmacy display name.

pharmacy.codestringoptional

The upstream marketplace's own code for that pharmacy. Present only for a marketplace fulfiller.

trackingNumberstring | nullrequired

Carrier tracking number. Null until the pharmacy reports it.

carrierstring | nullrequired

Shipping carrier, e.g. UPS.

shipmentsarray of objectoptional

Every carrier tracking reference we hold for this order, INCLUDING the boxes its split legs shipped in. A multi-product order that no single pharmacy can fill is split into one child order per pharmacy, and the CHILDREN carry the tracking, so trackingNumber above describes only this order's own primary box and is null for a split order. This array is the one place a split order's boxes appear. An entry is a tracking REFERENCE, not a confirmed shipment and not a box count: an order corrected back to packing keeps its tracking on purpose, so the length of this array does not establish how much has shipped. Match entries by trackingNumber, not by position, which can change as later boxes are reported. Empty until the pharmacy reports anything.

shipments[].axiomOrderIdintegerrequired

The order this reference belongs to: this order, or one of its split legs. NOT a shipment id, and not unique, since one order shipping two boxes repeats it.

shipments[].trackingNumberstringrequired

 

shipments[].carrierstring | nulloptional

Shipping carrier, e.g. UPS.

shippedAtstring (date-time) | nullrequired

 

deliveredAtstring (date-time) | nullrequired

 

occurredAtstring (date-time)required

 

Responses ​

StatusMeaning
200Acknowledged.

Delivery

http
POST /your-endpoint
Content-Type: application/json
User-Agent: axiom-connect-webhook/1.0
X-Axiom-Timestamp: 1767225600
X-Axiom-Signature: sha256=<hex>
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 accepted but cannot be filled ​

EVENTorder.failed

Sent when Axiom cannot route an accepted order or cannot send it to the pharmacy. An order the pharmacy itself reports as failed does NOT send this event (nor order.status_changed): read the order. Receiving it does not mean the order is safe to resubmit; read the order's failure first. Same signing and retry semantics as order.status_changed.

Payload ​

event"order.failed"required

 

orderIdintegerrequired

 

partnerOrderIdstringrequired

 

pharmacyobject | nullrequired

The pharmacy the order was routed to before it failed, if any. Null when it failed before routing (e.g. no_eligible_pharmacy). Same shape as the order.status_changed webhook.

pharmacy.idinteger | nullrequired

NULL when a marketplace fulfilled the order and picked the pharmacy itself.

pharmacy.namestringrequired

 

pharmacy.codestringoptional

The upstream marketplace's own code for that pharmacy, when there is one.

codestringrequired

no_eligible_pharmacy: no pharmacy is licensed / able to compound this order right now. hard_rule_conflict: a routing rule forbids every candidate pharmacy. routing_failed: routing exhausted its retries. pharmacy_dispatch_failed: the order routed to a pharmacy but sending it to that pharmacy's system failed after retries. All four arrive on the order.failed event. Note on timing for pharmacy_dispatch_failed: when the cause is the pharmacy being unavailable, or a configuration problem on Axiom's side, the order is HELD rather than failed, because nothing you could change about the order would help. It keeps its routed status while held, and no order.failed is sent while Axiom is still working the problem. An order the pharmacy actually REJECTED still fails immediately, because that one does need your attention. A held order is resolved by an Axiom operator, who either places it or cancels it, and a cancellation reaches you the usual way. So treat routed as still in progress, not as a guarantee the pharmacy has accepted it. One of: no_eligible_pharmacy, hard_rule_conflict, routing_failed, pharmacy_dispatch_failed.

messagestringrequired

Human-readable explanation.

retryablebooleanrequired

Whether resubmitting could succeed. Currently always false for these routing failures; Axiom reviews them.

occurredAtstring (date-time)required

 

Responses ​

StatusMeaning
200Acknowledged.

Delivery

http
POST /your-endpoint
Content-Type: application/json
User-Agent: axiom-connect-webhook/1.0
X-Axiom-Timestamp: 1767225600
X-Axiom-Signature: sha256=<hex>
json
{
  "event": "order.failed",
  "orderId": 184019,
  "partnerOrderId": "ACME-0001",
  "code": "no_eligible_pharmacy",
  "message": "No pharmacy is currently eligible to fill this order (licensing or availability). Axiom is reviewing.",
  "retryable": false,
  "pharmacy": null,
  "occurredAt": "2026-10-26T15:02:12.418Z"
}

A product in your catalog became orderable, or stopped being orderable ​

EVENTproduct.availability_changed

Fired when a SKU YOU can order changes availability: it goes out of stock, comes back, is switched off, or is held until a launch date. Built so you do not have to poll GET /api/inbound/{partnerSlug}/catalog on a timer to notice.

ONE EVENT, NOT A BACKORDERED/BACK-IN-STOCK PAIR. A product can be blocked for three independent reasons at once, so "no longer backordered" is not the same as "available now". Read product.orderable; use previous.reasons against product.reasons when you want to know which blocker moved.

KEEP THE HIGHEST REVISION you have seen for a sku and ignore a lower one arriving later. Deliveries retry independently, so a later transition can reach you BEFORE an earlier one, and applying whatever arrived last would reopen a product we closed.

DELIVERY IS A FEW MINUTES BEHIND THE CHANGE, not instant, and a bulk catalog update is published in batches, so a change to many SKUs at once arrives over several minutes. We detect changes by re-evaluating availability on a sweep, which is what lets a date hold expiring produce an event at all: nothing is written when a hold lapses, so there is no write to hook.

WE ATTEMPT TO QUEUE EACH CHANGE ONCE. If we cannot create the delivery we do not come back to it, and you simply will not receive that one, so THE POLL IS THE SOURCE OF TRUTH exactly as for notices: treat a missed event as normal and reconcile on your next poll of GET /api/inbound/{partnerSlug}/catalog. What this event buys you is not having to poll on a tight timer, not never polling.

A QUEUED DELIVERY IS STILL RETRIED, so you can also receive the SAME sku and revision more than once: an acknowledgement we never see looks identical to a failure. Handle duplicates by ignoring a revision you have already applied. Missing one and receiving one twice are both normal.

NOT SENT: a product being ADDED to your catalog, or removed from it. Those are catalog membership, not availability. Poll the catalog for those, as you do today.

Scoped to your own catalog. A SKU your account cannot order is never named here. partner names the recipient, which matters if several of your accounts post to one receiver URL.

Payload ​

event"product.availability_changed"required

 

partnerstringrequired

Your partner slug.

environmentstringrequired

One of: sandbox, production.

productobjectrequired

 

product.orderablebooleanrequired

Whether the PRODUCT itself can be ordered at that instant. It is not a promise that a given order will be accepted: ship-state licensing, catalog membership and pricing are separate gates, and the catalog poll remains the source of truth.

product.reasonsarray of stringrequired

Why it cannot be ordered, empty when it can. These are INDEPENDENT and more than one can apply at once, which is why this is a list and why clearing one of them does not mean the product is available. inactive - switched off in our catalog. Only a person clears it. held - not orderable before unavailableUntil. THIS ONE LIFTS BY ITSELF on that date. backordered - out of stock. Only a person clears it, never a date.

product.unavailableUntilstring (date-time) | nullrequired

When the date hold lifts. Present only while held is in reasons. Changing it is published as its own event, so a launch moving is something you hear about.

product.expectedBackAtstring (date-time) | nullrequired

A SUGGESTED return date, when we have one. Present only while backordered. Informational: it decides nothing and it does NOT clear the backorder when it passes. A correction to it is published with orderable unchanged on both sides.

product.skustringrequired

The canonical SKU

product.revisionintegerrequired

Increments once per published change for this sku. Keep the highest you have seen and ignore anything lower or equal.

previousobjectrequired

What the availability was before this change.

previous.orderablebooleanrequired

Whether the PRODUCT itself can be ordered at that instant. It is not a promise that a given order will be accepted: ship-state licensing, catalog membership and pricing are separate gates, and the catalog poll remains the source of truth.

previous.reasonsarray of stringrequired

Why it cannot be ordered, empty when it can. These are INDEPENDENT and more than one can apply at once, which is why this is a list and why clearing one of them does not mean the product is available. inactive - switched off in our catalog. Only a person clears it. held - not orderable before unavailableUntil. THIS ONE LIFTS BY ITSELF on that date. backordered - out of stock. Only a person clears it, never a date.

previous.unavailableUntilstring (date-time) | nullrequired

When the date hold lifts. Present only while held is in reasons. Changing it is published as its own event, so a launch moving is something you hear about.

previous.expectedBackAtstring (date-time) | nullrequired

A SUGGESTED return date, when we have one. Present only while backordered. Informational: it decides nothing and it does NOT clear the backorder when it passes. A correction to it is published with orderable unchanged on both sides.

observedAtstring (date-time)required

 

Responses ​

StatusMeaning
200Acknowledged.

Delivery

http
POST /your-endpoint
Content-Type: application/json
User-Agent: axiom-connect-webhook/1.0
X-Axiom-Timestamp: 1767225600
X-Axiom-Signature: sha256=<hex>
json
{
  "event": "product.availability_changed",
  "partner": "acme-health",
  "environment": "production",
  "product": {
    "sku": "EXAMPLE-SKU-1",
    "revision": 7,
    "orderable": false,
    "reasons": [
      "backordered"
    ],
    "unavailableUntil": null,
    "expectedBackAt": "2026-11-03T05:00:00.000Z"
  },
  "previous": {
    "orderable": true,
    "reasons": [],
    "unavailableUntil": null,
    "expectedBackAt": null
  },
  "observedAt": "2026-10-26T15:02:12.418Z"
}

A broadcast notice went live for you ​

EVENTnotice.published

Fired when Axiom publishes an announcement addressed to you or to every partner. The same notices are always readable at GET /api/inbound/{partnerSlug}/notices.

THE POLL IS THE SOURCE OF TRUTH. This push is a nudge, not a guarantee: treat a missed delivery as normal and reconcile on your next poll. Dedupe on notice.id and notice.revision, never on the text.

KEEP THE HIGHEST REVISION you have seen for an id, and ignore a lower one arriving later. Deliveries retry independently, so a notice.withdrawn at revision 2 can reach you BEFORE the notice.published at revision 1 that it withdraws. Treating the pair as "not seen before" would put a withdrawn announcement back on your page.

Scoped to you and to the lane of the endpoint: a production announcement is queued only for your production endpoints. (A delivery already queued keeps its URL, so use a different URL per environment rather than re-registering one URL in the other lane.) partner names the recipient, which matters if several of your accounts post to one receiver URL.

Payload ​

event"notice.published"required

 

partnerstringrequired

Your partner slug.

noticeobjectrequired

 

notice.idintegerrequired

 

notice.revisionintegerrequired

Bumped whenever the notice is edited or withdrawn. Dedupe on (id, revision): the same id with a higher revision is the same announcement corrected, not a new one.

notice.severitystringrequired

One of: info, warning.

notice.titlestringrequired

 

notice.bodystringrequired

 

notice.startsAtstring (date-time)required

 

notice.endsAtstring (date-time) | nulloptional

Null means until we withdraw it. Stop rendering at this instant yourself; nothing is pushed when it passes.

Responses ​

StatusMeaning
200Acknowledged.

Delivery

http
POST /your-endpoint
Content-Type: application/json
User-Agent: axiom-connect-webhook/1.0
X-Axiom-Timestamp: 1767225600
X-Axiom-Signature: sha256=<hex>
json
{
  "event": "notice.published",
  "partner": "acme-health",
  "notice": {
    "id": 31,
    "revision": 1,
    "severity": "warning",
    "title": "EXAMPLE-SKU-1 is backordered",
    "body": "New orders for EXAMPLE-SKU-1 are refused until it is back in stock. Orders already placed are not affected.",
    "startsAt": "2026-10-26T13:00:00.000Z",
    "endsAt": null
  }
}

A broadcast notice was taken down ​

EVENTnotice.withdrawn

Stop rendering the notice with this id. It arrives with an incremented revision.

This is the only signal that removes a notice. Expiry is NOT an event: enforce endsAt locally and stop showing a notice that has passed it.

Payload ​

event"notice.withdrawn"required

 

partnerstringrequired

 

noticeobjectrequired

 

notice.idintegerrequired

 

notice.revisionintegerrequired

Bumped whenever the notice is edited or withdrawn. Dedupe on (id, revision): the same id with a higher revision is the same announcement corrected, not a new one.

notice.severitystringrequired

One of: info, warning.

notice.titlestringrequired

 

notice.bodystringrequired

 

notice.startsAtstring (date-time)required

 

notice.endsAtstring (date-time) | nulloptional

Null means until we withdraw it. Stop rendering at this instant yourself; nothing is pushed when it passes.

Responses ​

StatusMeaning
200Acknowledged.

Delivery

http
POST /your-endpoint
Content-Type: application/json
User-Agent: axiom-connect-webhook/1.0
X-Axiom-Timestamp: 1767225600
X-Axiom-Signature: sha256=<hex>
json
{
  "event": "notice.withdrawn",
  "partner": "acme-health",
  "notice": {
    "id": 31,
    "revision": 2,
    "severity": "warning",
    "title": "EXAMPLE-SKU-1 is backordered",
    "body": "New orders for EXAMPLE-SKU-1 are refused until it is back in stock. Orders already placed are not affected.",
    "startsAt": "2026-10-26T13:00:00.000Z",
    "endsAt": null
  }
}