---
title: "Webhooks"
description: "Verify, save, and process Affinity events."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.affinityrx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

Affinity sends signed webhook events for asynchronous changes. Endpoint URLs must use public HTTPS and contain at most 2,048 characters. This service limit keeps endpoint requests bounded; it is not a general URL standard. Configure separate endpoints in Test mode and Live mode.

## Choose the endpoint owner

Practice, pharmacy, and platform API keys manage their own endpoints at `/v1/webhook-endpoints`.
Without an organization header, the API uses the key's organization.
Each endpoint response includes its owning `organizationId`.

The owner determines which endpoints and event history you can manage.
The `subscribedEvents` and `practiceIds` fields determine which authorized events an endpoint receives.

## Receive practice events as a platform

Use your platform key without an organization header to create a platform-owned endpoint.
Set `practiceIds` to select connected practices in the key's mode:

```json
{
  "url": "https://your-service.example/affinity/events",
  "subscribedEvents": ["order.created", "order.signed"],
  "practiceIds": ["prac_..."]
}
```

This filter narrows delivery. It does not grant access to another practice's events.
For orders, the platform receives events for orders attributed to that platform.
Connecting a practice does not subscribe the platform to all orders in that practice.

An empty `practiceIds` array receives all otherwise-authorized events.
A nonempty filter excludes events without a matching practice.
Only platform-owned endpoints accept a nonempty practice filter.
On updates, omitted `practiceIds` preserves the filter; an empty array removes it.
Subscription changes apply to newly generated events.

## Delegate endpoint management

A practice or pharmacy can grant a platform permission to manage its endpoints.
The endpoint remains owned by the practice or pharmacy.

First, use the owner's API key to [grant webhook access](/api/saveWebhookGrant/):

```http
PUT /v1/webhook-grants/acct_<platform-id>
Authorization: Bearer <owner-api-key>
Idempotency-Key: <unique-request-key>
Content-Type: application/json

{"scopes":["webhooks:read","webhooks:write"]}
```

The owner key requires `webhooks:write`.
A practice must already be connected to the platform in the selected mode.
A platform cannot grant itself access.
Grants apply only to webhook resources and only in the owner's key mode.

Then use the platform key with the owner's public organization ID:

```http
GET /v1/webhook-endpoints
Authorization: Bearer <platform-api-key>
X-Affinity-Organization-Id: <owner-organization-id>
```

Use the same header when updating, disabling, testing, rotating secrets, or reading and replaying the owner's events.
The platform key and the grant must both permit the requested action.
An unauthorized organization selection returns `403`; the API does not fall back to the key's organization.
The header does not change the caller's identity or the key's Test or Live mode.

Use [List access grants](/api/listWebhookGrants/) to inspect grants with the owner's key.
Use [Revoke webhook access](/api/revokeWebhookGrant/) to remove a platform's access.
Revocation also prevents access to saved mutation responses.
Existing endpoints remain owned by the practice or pharmacy and continue operating after revocation.

All API-key mutations require `Idempotency-Key`.
Use a different idempotency key when changing the selected organization.

## Follow signing and fulfillment

New orders are unsigned drafts. A clinician opens the order, reviews its prescriptions, and signs or rejects it. There is no separate review-request or approval step.

Use `order.updated` for draft edits, `order.signed` for the signature, and `order.rejected` for a clinician's rejection. Signing does not confirm pharmacy submission. Follow `order.submitted`, `order.accepted`, and shipment events for fulfillment.

A rejection closes the complete unsigned order permanently. Snapshot events include the decision reason, time, clinician identity, and prescribing provider. Thin events contain the order ID; retrieve the authenticated order to read the decision.

Historical `order.review_requested` and `order.changes_requested` events remain readable and replayable. New orders do not emit them.

## Verify the signature

Pass the unmodified request bytes to the TypeScript SDK. Verify the signature before you parse the JSON body.

```typescript
import { AffinityWebhookVerificationError, verifyAffinityWebhook } from "@affinity-health/sdk";

export async function handleAffinityWebhook(request: Request) {
  try {
const event = await verifyAffinityWebhook({
  body: await request.arrayBuffer(),
  secret: process.env.AFFINITY_WEBHOOK_SECRET!,
  signature: request.headers.get("affinity-signature"),
});

await saveEvent(event);
return new Response(null, { status: 204 });
  } catch (error) {
if (error instanceof AffinityWebhookVerificationError) {
  return Response.json({ error: error.code }, { status: 400 });
}
throw error;
  }
}
```

The signature header uses a timestamp and an HMAC-SHA256 digest. The SDK checks both values.

## Process each event once

Affinity can deliver the same event more than once. Store the event ID in the same transaction as your state change.

Save the event durably, then return `2xx`. Process it asynchronously after acknowledgment.
Retrieve the current API resource in that background job when event order matters.
Do not wait for order retrieval or other downstream work before responding.

## Delivery order and current state

Affinity does not guarantee event delivery order. For example, `order.submitted` can arrive before
`order.created` or `order.signed`. Retries and manual replay can deliver older events after newer ones.
This follows [Stripe's event-ordering guidance](https://docs.stripe.com/webhooks#event-ordering).

Verify the signature, durably deduplicate by event ID, and acknowledge with `2xx`. In your background
job, retrieve the current order with the same organization and mode before updating its displayed
state. Do not overwrite current state with an older snapshot. Event timestamps describe event
creation, not delivery sequence, and two events can share a timestamp.

The `2026-09-28` webhook contract uses the following order `status` values. An unsigned order has
`draft` in a webhook snapshot and `requires_provider_signature` in an order API response. A signed
order has `ready` in both. A partially submitted batch has `submitted`, `processing`, `shipped`, or
`blocked` in a webhook snapshot, based on its fulfillment progress. Its order API response can use
`partially_submitted`. The `order.accepted` event name does not create a new status. The Sign order
response returns `ready`.

These webhook values also apply to retries, replays, event-detail payloads, and
`data.previous_attributes`. Affinity keeps the stored event snapshot unchanged. Retrieve the order
for its current status.

## Timeouts, retries, and suspension

Affinity allows three seconds for DNS resolution and the TCP/TLS connection and 15 seconds for the HTTP response.
A connected receiver has the remaining response time to save and acknowledge the event.
DNS failures report `dns_error`; the connection deadline reports `connect_timeout`; an overdue response reports `response_timeout`.

Network failures, `408`, `409`, `425`, `429`, and `5xx` responses use the full retry schedule.
After the first attempt, the delays between attempts are:

| Mode | Retry delays                                                                              |
| ---- | ----------------------------------------------------------------------------------------- |
| Test | 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours                                    |
| Live | 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 18 hours, 2 days, 3 days |

Other `4xx` responses retry after 2 minutes and 30 minutes. Each delay varies by up to 20 percent.
Redirects fail without a retry. Affinity never follows the redirect.

`consecutiveFailures` counts deliveries that have exhausted their retries, including failures with no retry.
It does not count individual failed attempts while a delivery is retrying.
A successful delivery resets the counter. Three consecutive exhausted deliveries suspend the endpoint.
Use the attempt records to inspect failures that are still retrying.

After correcting the receiver, reactivate the endpoint with a partial update:

```http
PATCH /v1/webhook-endpoints/whe_<endpoint-id>
Authorization: Bearer <api-key>
Idempotency-Key: <unique-request-key>
Content-Type: application/json

{"status":"active"}
```

Reactivation resets the failure counter. Omitted fields retain their existing values. Optional fields do not accept `null`: clear a description with `""`, or clear subscriptions and practice filters with `[]`.
Replay failed events after reactivation to recover missed updates.

## Event names

An empty `subscribedEvents` array subscribes to all supported events. Unknown names return `400`.

| Events                                                                  | Meaning                                         |
| ----------------------------------------------------------------------- | ----------------------------------------------- |
| `webhook_endpoint.test`                                                 | Endpoint test                                   |
| `order.created`, `order.updated`                                        | Draft creation and order changes                |
| `order.signed`, `order.rejected`                                        | Clinician decision                              |
| `order.submitted`, `order.accepted`, `order.processing`                 | Submission and pharmacy progress                |
| `order.shipped`, `order.delivered`                                      | Shipment progress                               |
| `order.blocked`, `order.cancelled`                                      | Fulfillment exception or confirmed cancellation |
| `cancellation.requested`, `cancellation.sent`                           | Cancellation is pending                         |
| `cancellation.confirmed`                                                | Pharmacy cancellation is confirmed              |
| `cancellation.rejected`, `cancellation.failed`, `cancellation.too_late` | Cancellation did not complete                   |
| `order.review_requested`, `order.changes_requested`                     | Historical review events, available for replay  |

## Protect webhook data

Thin events contain a resource ID and object type. Read the authenticated resource when you need current or sensitive data.

Do not write request bodies, secrets, patient data, or prescription data to application logs.

## Test the endpoint

1. Create a Test webhook endpoint.
2. Subscribe only to the events that your service handles.
3. Send a test event.
4. Confirm signature failure behavior.
5. Confirm deduplication and replay behavior.

Create the Live endpoint separately after Affinity approves Live access.

Source: https://docs.affinityrx.com/guides/webhooks/index.mdx
