Skip to content

Receive order updates

Register a webhook endpoint with the API, verify each event's signature and keep your copy of each order current.

Updated View as Markdown

Affinity sends a webhook event when an order changes. Use the events to keep your copy of each order current.

Each event goes to the platform of the order’s practice. If the practice has no platform, the event goes to the practice. The recipient manages endpoints in its own organization and mode.

Add an endpoint

Use an API key for the platform or independent practice that receives the events. A platform-managed practice cannot have endpoints.

  1. Use a Test API key to register a Test endpoint.
  2. Send POST /v1/webhook-endpoints with a public https URL.
curl https://api.affinityrx.com/v1/webhook-endpoints \
  -H "Idempotency-Key: webhook-create-42" \
  -H "Authorization: Bearer $AFFINITY_TEST_API_KEY" \
  -H "Affinity-Version: 2026-10-28" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/affinity/events"}'

The request returns 200 with the endpoint ID, URL, mode, times, and signing secret:

{
  "id": "whe_01j9z3k4m5n6p7q8r9s0t1v2wa",
  "url": "https://example.com/affinity/events",
  "mode": "test",
  "createdAt": "2026-10-22T18:30:00.000Z",
  "updatedAt": "2026-10-22T18:30:00.000Z",
  "secret": "whsec_test_…"
}
  1. Store secret in your server configuration. Affinity returns the secret only in the creation response.
  2. For Live mode, repeat the request with a Live API key. Store the new signing secret separately.

A key manages endpoints only for its own organization and mode. A platform key cannot manage endpoints for its practices. Creation, URL updates and failed-delivery resends require Idempotency-Key. Read Retry safely. A creation replay rebuilds the creation response from the endpoint and original request. It returns the original URL, creation times and signing secret for 24 hours. After the endpoint is deleted, the same replay returns 404 not_found. Affinity does not store the secret in the idempotency record.

Use GET /v1/webhook-endpoints to list endpoints. Endpoints have no name or description field. Keep each endpoint ID and URL in your integration configuration to identify it. Use PATCH /v1/webhook-endpoints/{webhookEndpointId} with url and a new Idempotency-Key to change the URL. Use DELETE /v1/webhook-endpoints/{webhookEndpointId} to delete an endpoint. A 404 on a retried DELETE /v1/webhook-endpoints/{webhookEndpointId} means the endpoint was already deleted.

If a URL fails public HTTPS validation or host resolution, create and update requests return 422 invalid_webhook_url. Read issues for url. Use port 443 without credentials or a fragment. The host must resolve to public addresses only.

An admin can also manage endpoints in the Affinity app. Open Settings → API, select Test or Live, and use Webhooks.

You can add up to 16 endpoints in each mode, for example one for staging and one for development. You can register the same URL more than once. Each registration gets deliveries signed with its own secret; a shared receiver must check each secret. Each event goes to every endpoint of its mode. Each endpoint has its own secret. A URL change keeps the secret. When you delete an endpoint, Affinity drops its events that are not delivered.

An endpoint created during an order receives only later events; read the order to get its current state. On a shared platform, each endpoint receives events for orders from every integration in its mode. Match data.order.id to your own saved order IDs before you change your records. Use externalOrderId for this check only if it is unique across your platform.

Rotate a signing secret

A URL change keeps the secret. To rotate the secret without losing deliveries:

  1. Create a second endpoint on the same URL.
  2. Store its new signing secret.
  3. Update your receiver to accept signatures from both secrets while both endpoints exist.
  4. Delete the old endpoint.

The new endpoint receives only events created after it exists. Read open orders, or keep the old endpoint until its pending deliveries finish, to cover the gap.

Send a test event

Open the actions menu of an endpoint. Click Send test event, then click Send. Affinity sends one webhook_endpoint.test event to that endpoint only, with data: {}. The event is signed like all events. Affinity does not retry it. The app shows whether your endpoint answered with a 2xx status.

Events

EventSent when
order.submittedAffinity accepted the order and sends it to the pharmacy.
order.processingThe pharmacy has the order.
order.shippedAt least one package left the pharmacy.
order.deliveredThe carrier delivered the order.
order.cancelledThe pharmacy confirmed the cancel, or cancelled the order itself.
order.failedThe pharmacy rejected the order, or Affinity could not send it.
order.updatedThe pharmacy adds, changes or voids a shipment, or refuses a cancel, including an immediate 409 cancel_refused.

Send test event in the app sends webhook_endpoint.test. It is not an order event.

Each status in an order’s statusHistory sends one order.<status> event.

During a status check, Affinity applies stored pharmacy callbacks by when the pharmacy says they occurred, then by arrival time and receipt sequence, before the current pharmacy result. A stored acceptance can send order.processing before order.shipped even if the current result is already shipped. Each event includes the packages known through that change. Late reports do not move the order backwards.

order.delivered is sent only when a pharmacy or carrier reports delivery. Some pharmacies do not report it, so a Live order can remain shipped. Use the shipment tracking information to follow the package. A simulated delivery in Test mode exercises your event handler; it does not mean the Live pharmacy will send delivery updates.

For a shipped Live order with carrier tracking on each package, Affinity checks the pharmacy after 7 days without a new carrier scan. Checks then run every 7 days until delivery or the 60-day shipped watch window ends. A new scan restarts the 7-day wait.

Watch windows limit routine checks. Signed pharmacy callbacks and verified carrier evidence still apply after a watch window ends. A late delivery starts the patient link’s 30-day delivery window when Affinity applies it.

Affinity does not send these, because nobody acts on them:

  • order.created. You created the order and have the response.
  • A cancel that answers 202. You have the response. An immediate 409 cancel_refused still sends one order.updated.
  • Pharmacy patient records, item splits before shipping and carrier scans.
  • Problems that the pharmacy must resolve. Affinity handles them with you outside the API.

Affinity can add event types later. Ignore an event type that you do not know, and answer 2xx.

The payload

{
  "id": "evt_01j9z3k4m5n6p7q8r9s0t1v2wa",
  "type": "order.shipped",
  "createdAt": "2026-10-22T18:30:00.000Z",
  "data": {
    "order": {
      "id": "ord_01j9z3k4m5n6p7q8r9s0t1v2w7",
      "externalOrderId": "cuvo-123",
      "status": "shipped",
      "failureReason": null,
      "cancelRequestedAt": null,
      "trackingUrl": "https://app.affinityrx.com/track/k3Jx9wQ2mZ7vL0pTn4Yc8Ud1Fh6sBe5RaVg2XoNiW0A",
      "shipments": [
        {
          "carrier": "UPS",
          "trackingNumber": "1Z999AA10123456784",
          "trackingUrl": "https://app.affinityrx.com/track/k3Jx9wQ2mZ7vL0pTn4Yc8Ud1Fh6sBe5RaVg2XoNiW0A"
        }
      ],
      "prescriptions": [
        { "id": "rx_01j9z3k4m5n6p7q8r9s0t1v2w9", "status": "shipped", "failureReason": null }
      ],
      "updatedAt": "2026-10-22T18:30:00.000Z"
    }
  }
}

data.order has only the fields that you act on. It has no patient data or prescription clinical details. To read the whole order, send GET /v1/orders/{orderId}. Affinity can add fields to data.order later. Ignore fields that you do not know.

The request has these headers:

Header Value
Affinity-Signature t=<Unix time in seconds>,v1=<signature>
Affinity-Event-Id The event id, for example evt_01j9z3k4m5n6p7q8r9s0t1v2wa.
Content-Type application/json

Verify the signature

Verify each request before you use it.

  1. Read the raw request body as bytes. Do not parse it and serialize it again.
  2. Split Affinity-Signature at the commas. Read t and v1.
  3. Calculate HMAC-SHA256 of the UTF-8 ${t}. prefix followed by the raw body bytes. Use the endpoint’s whole signing secret (whsec_…) as the key. Encode the result as lowercase hex.
  4. Compare your result with v1 in constant time. Reject the request if they are different.
  5. Reject the request if t is more than 300 seconds from your current time.

Affinity signs each delivery attempt, including retries and resends, with the current t and a signature for that timestamp. The 300-second check applies to delivery time, not the event’s creation time. Keep the same tolerance for late deliveries. Keep your server clock synchronized, for example with Network Time Protocol (NTP). A clock error greater than five minutes can cause valid deliveries to fail verification.

Node.js accepts a Uint8Array or a Buffer:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyAffinityWebhook(rawBody, header, secret, now = Date.now() / 1000) {
  if (typeof header !== "string") return false;
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`, "utf8")
    .update(rawBody)
    .digest("hex");
  const received = Buffer.from(parts.v1 ?? "", "utf8");
  return received.length === expected.length && timingSafeEqual(received, Buffer.from(expected));
}

Python:

import hashlib
import hmac
import re
import time

def verify_affinity_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    if not isinstance(header, str):
        return False
    parts = re.fullmatch(r"t=([0-9]+),v1=([0-9a-f]{64})", header)
    if parts is None:
        return False
    try:
        timestamp = int(parts[1])
    except ValueError:
        return False
    if abs(time.time() - timestamp) > 300:
        return False
    message = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts[2])

Use Send test event in the Affinity app to test your verification code. This action has no public API route. For an API-only test, create a synthetic Test order and verify its signed events.

Answer fast

Answer with a 2xx status within 10 seconds. Store the event, answer, and then do the work. Affinity does not follow redirects. A redirect, a timeout or any status other than 2xx is a failed attempt.

Retries

Affinity retries a failed delivery with backoff. It makes up to 12 attempts. The wait after attempt n is 2n minutes: 2, 4, 8 … 1,024 minutes, then 24 hours. The last attempt is about 58 hours after the first. After 12 failed attempts, the delivery has failed and Affinity stops. Resend it after you fix your receiver.

Resend failed deliveries

Event and delivery history is retained for 33 days. Pending deliveries and recent resend attempts remain until their retries finish.

After an outage, fix your receiver. Use this request to queue failed order event deliveries from the last 30 days. It also makes pending deliveries waiting on a later retry due now. The 30-day limit applies to failed deliveries and starts at each event’s creation time.

  1. Use an API key for the endpoint’s organization and mode.
  2. Send POST /v1/webhook-endpoints/{webhookEndpointId}/resend-failed.
  3. Send a unique Idempotency-Key header for the request.
curl -X POST "https://api.affinityrx.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/resend-failed" \
  -H "Authorization: Bearer $AFFINITY_TEST_API_KEY" \
  -H "Affinity-Version: 2026-10-28" \
  -H "Idempotency-Key: $RESEND_REQUEST_ID"

The response counts failed deliveries queued for retry and pending deliveries made due now:

{ "queued": 3 }

The worker sends the queued deliveries with their original event IDs and payloads. Each failed delivery gets up to 12 new attempts. Pending deliveries keep their attempt counts. Resent events of one order go out in the order they happened. An old resend can arrive beside or after newer events; compare data.order.updatedAt before you update your copy. This request does not send events immediately. Pending deliveries already due, delivered events, and one-time test events stay unchanged.

If the response is uncertain, repeat the request with the same Idempotency-Key. Affinity returns the original count without queuing deliveries again. Use a new key for a new request. The count is 0 if no eligible failures or pending deliveries waiting on a later retry remain.

For Live mode, use a Live API key and a Live endpoint ID. An admin can also select Resend failed deliveries from the endpoint’s menu in Settings → API. The endpoint row shows the queued count.

For older events, read each open order with GET /v1/orders/{orderId}. You can also list orders with GET /v1/orders?status=processing.

Affinity records each attempt before it sends the event. If Affinity cannot save the result, the attempt still counts toward the retry limit and keeps its retry time.

Order of events

Each endpoint gets the events of one order one at a time, in the order they happened. An event waits until every earlier event of the same order to the same endpoint is delivered or has failed its first 6 attempts, which takes about an hour. So a retried order.processing does not arrive after order.shipped when your endpoint recovers in that time. Events of other orders and other endpoints do not wait. Affinity takes turns across organizations when choosing deliveries, so another organization’s slow endpoints or older backlog cannot occupy every sender ahead of your due events.

An older event can arrive after a newer one when your endpoint refuses it for longer: the earlier event failed 6 attempts, later events went out, and the earlier event was then delivered on a later retry or resent. Keep the order with the newest data.order.updatedAt, and ignore an event with an older updatedAt than the one that you stored for the same order. updatedAt strictly increases with each change to the order, even when server clocks agree or move backward. Different changes to the same order do not share an updatedAt. A duplicate delivery keeps the original event timestamp. On order reads, practice.archivedAt reflects the practice’s current state. Archiving a practice does not advance the order timestamp or send an order event. Keep the last event timestamp separate from timestamps read through GET.

You can seed your order status from the create response. A GET response gives the current state, including cancelRequestedAt, but its updatedAt can be newer than the last event without a new event. Do not use a GET timestamp as the cutoff for incoming events. Store the last accepted event ID and event timestamp separately. Store the status and cancelRequestedAt from a cancel response, then reconcile them from later reads and events.

order.submitted can arrive before the POST /v1/orders response. Match the order by externalOrderId, or read it with GET /v1/orders/{orderId}.

Duplicates

An event can arrive more than once. Use Affinity-Event-Id to find a duplicate. It equals the id in the body. Process each event ID one time.

  • A retry and a resend of a failed delivery send the same event ID and the same payload.
  • Store the event ID before you do the work, and keep it for at least 30 days.

Prescription updates

Each order event includes data.order.prescriptions, with each prescription’s id, status and nullable failureReason. A prescription.failed or prescription.cancelled event also includes data.prescriptionId. These events report one prescription changing without the order reaching that status. PerfectRx prescriptions move with the order, so PerfectRx sends no separate prescription events.

Navigation

Send feedback

Don't include patient information.

Type to search…

↑↓ navigate↵ selectEsc close