# Guide to athenahealth Webhooks: Features and Best Practices

athenahealth webhooks tell your application when something changes in athenaOne: an appointment is scheduled, checked in or cancelled, an encounter is signed off, a lab result arrives, a patient record is updated. They come from athenahealth's Event Subscription Platform, which delivers healthcare domain events as near real-time notifications, so integrations don't have to poll the athenahealth APIs for changes.

This guide covers how athenahealth subscriptions are created and signed, what a notification Bundle contains, the topics you can subscribe to, how delivery and retries work, and the practices that keep an athenahealth receiver reliable.

## What are athenahealth webhooks?

athenahealth's Event Subscription Platform largely follows the FHIR Subscriptions R5 Backport implementation guide. You create a FHIR `Subscription` for one topic and one practice, give it your HTTPS endpoint, and athenahealth sends a `POST` with a FHIR `Bundle` to that endpoint whenever a matching event happens. The platform supports one channel type, `rest-hook`, and one payload type, `id-only`, so a notification tells you which resource changed and you fetch its current state from the API.

Signing follows the W3C WebSub specification. When you create the Subscription, you send an `X-Hub-Secret` header with a random value you choose. athenahealth then signs every notification for that Subscription with an `X-Hub-Signature: sha256=<hex>` header, the HMAC-SHA256 of the raw request body keyed with that value. The header is optional, but athenahealth "strongly recommends" it. Without it, notifications are unsigned.

The Subscription and SubscriptionTopic endpoints are in a limited alpha rollout, so you need athenahealth to enable them for your app.

## athenahealth webhook features

| Feature | Details |
| --- | --- |
| Configuration | FHIR API: `POST`, `PUT`, `DELETE` and `GET` on `/fhir/r4/Subscription` |
| Access | 2-legged OAuth apps, with scopes requested from the athenahealth API operations team |
| Subscription model | One Subscription per topic and per practice. Many Subscriptions can share one URL |
| Filters | `ah-practice` (mandatory) and `ah-department`, combined with AND |
| Payload | `id-only` FHIR `Bundle` of type `history` |
| Signature header | `X-Hub-Signature: sha256=<hex>` |
| Scheme | WebSub: HMAC-SHA256, hex. athenahealth says the method may change |
| Signed content | The raw request body, nothing else |
| Secret | The `X-Hub-Secret` value you send when creating the Subscription |
| Delivery | At least once, not ordered. Several events can share one Bundle |
| Response window | `2xx` within 2 seconds, a hard limit |
| Retries | Up to 1 hour (error-handling section) or 7 days (performance section), then a dead letter queue with replay for up to 7 days |
| Endpoint | HTTPS on port 443, with a valid certificate from a widely trusted authority |

athenahealth publishes no source IP list. Its README says authentication is handled by verifying the signature.

## Common events

athenahealth calls events topics, and names them `Resource.action`. The README lists 79 topics across 20 resources. `GET /SubscriptionTopic` returns the live list with a description of each.

| Resource | Topics |
| --- | --- |
| Appointment | `cancel`, `check-in`, `check-out`, `freeze`, `reschedule`, `schedule`, `unfreeze`, `update` |
| Encounter | `check-in`, `reopen`, `signoff` |
| Patient | `create`, `delete`, `merge`, `update` |
| PatientCase | `add-note`, `create`, `update` |
| PatientProblem, ClinicalEncounterDiagnosis | `create`, `delete`, `update` |
| LabResult | `close`, `create`, `update` |
| ImagingResult | `close`, `create`, `delete`, `reopen`, `update` |
| Order | `cancel`, `deny`, `perform`, `signoff`, `submit`, `update` |
| Prescription | `create`, `delete`, `preapprove`, `refill-create`, `refill-update`, `reopen`, `update` |
| HistoricalMedication, HistoricalVaccine | `create`, `delete`, `update` |
| Claim | `create`, `delete`, `update` |
| Provider, ReferringProvider | `create`, `delete`, `undelete`, `update` |
| AdminDocument, ClinicalDocument, MedicalRecord, PatientInfoOrder (alpha) | `create`, `update`, `delete`, `remove-after-reclassify` |
| Letter (alpha) | `create` |

Some resources accept only the `ah-practice` filter, and Claim and Provider accept `ah-department` for one topic each. Use the targeted topics where they exist. athenahealth's own example is `Appointment.check-in`: subscribing to it is safer than listening to `Appointment.update` and checking the status, because the appointment may have changed again by the time you fetch it.

Every notification is a FHIR `Bundle`. Here is athenahealth's `Patient.update` example, trimmed and with its comments removed:

```json
{
  "resourceType": "Bundle",
  "id": "3945182f-d315-4dbf-9259-09d863c7e7da",
  "type": "history",
  "timestamp": "2021-03-31T16:20:01.123Z",
  "entry": [
    {
      "fullUrl": "urn:uuid:c144782b-da2f-4125-a9e2-9fa4b9085a40",
      "resource": {
        "resourceType": "SubscriptionStatus",
        "status": "active",
        "type": "event-notification",
        "eventsInNotification": 1,
        "subscription": { "reference": "Subscription/a9c3784c-9f56-4b32-95b0-882868d39e58" },
        "topic": "https://api.platform.athenahealth.com/fhir/r4/SubscriptionTopic/Patient.update",
        "notificationEvent": [
          {
            "eventNumber": "1",
            "id": "cb6cc377-ee38-31ba-9482-f93f821cd169",
            "timestamp": "2021-03-31T16:20:12.000Z",
            "focus": {
              "reference": "Patient/a-432.E-528595",
              "identifier": { "system": "urn:athenahealth:athenanet:patient:432", "value": "528595" }
            }
          }
        ]
      }
    },
    {
      "fullUrl": "urn:uuid:cb6cc377-ee38-31ba-9482-f93f821cd169",
      "resource": {
        "resourceType": "AuditEvent",
        "id": "cb6cc377-ee38-31ba-9482-f93f821cd169",
        "type": { "system": "https://fhir.athena.io/CodeSystem/SubscriptionTopic", "code": "Patient.update" },
        "recorded": "2021-03-31T16:20:12.000Z",
        "agent": [{ "who": { "identifier": { "value": "Athena" } }, "requestor": true }]
      }
    }
  ]
}

```

* `entry[0]` is always a `SubscriptionStatus`. Its `topic` is a full SubscriptionTopic URL: take the part after `SubscriptionTopic/` to get `Patient.update`.
* `notificationEvent` can hold several events of the same topic, and its length always equals `eventsInNotification`. Each event has a unique `id`, the database commit `timestamp`, and a `focus` with a FHIR reference, a proprietary identifier, or both.
* Each event has a matching `AuditEvent` entry with the same `id`. It records who triggered the event, and carries topic-specific extensions such as the patient, department or chart sharing group.
* The top-level `timestamp` is when the notification was sent, not when the event happened.
* athenahealth fills `eventsSinceSubscriptionStart`, `eventsInNotification` and `eventNumber` relative to the current Bundle, which differs from the Backport guide. Don't use them to detect gaps.

## Setting up athenahealth webhooks

You need an athenahealth Developer Portal account with a 2-legged OAuth app, and the scopes `system/SubscriptionTopic.read`, `system/Subscription.write` and `system/Subscription.read`. These scopes aren't in the self-service UI, so request them from the athenahealth API operations team.

1. Generate a random secret, for example with `openssl rand -hex 32`, and store it server-side as `ATHENAHEALTH_WEBHOOK_SECRET`.
2. Create a Subscription for each topic and practice, sending the secret in `X-Hub-Secret`:

```bash
curl --request POST https://api.platform.athenahealth.com/fhir/r4/Subscription \
  --header "Authorization: Bearer $ATHENA_TOKEN" \
  --header 'Content-Type: application/json' \
  --header "X-Hub-Secret: $ATHENAHEALTH_WEBHOOK_SECRET" \
  --data-raw '{
    "resourceType": "Subscription",
    "meta": { "profile": ["http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription"] },
    "status": "requested",
    "reason": "Appointment sync",
    "criteria": "https://api.platform.athenahealth.com/fhir/r4/SubscriptionTopic/Appointment.check-in",
    "_criteria": { "extension": [{
      "url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
      "valueString": "ah-practice=Organization/a-1.Practice-195000"
    }] },
    "channel": {
      "type": "rest-hook",
      "endpoint": "https://example.com/webhooks/athenahealth",
      "payload": "application/fhir+json",
      "_payload": { "extension": [{
        "url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-payload-content",
        "valueCode": "id-only"
      }] }
    }
  }'

```

athenahealth's example also sets an `end` date for the Subscription. Use `https://api.preview.platform.athenahealth.com/fhir/r4` for the preview environment.

To change a Subscription's filters, `PUT` the whole resource to `/Subscription/{id}`. athenahealth's update example sends `X-Hub-Secret` again. You can't change the webhook URL of an existing Subscription: delete it and create a new one. Filter changes can take up to 15 minutes to apply. With an `ah-department` filter, list every practice in `ah-practice` too, because the two filters combine with AND and events for a missing practice are dropped.

If you receive athenahealth notifications through Hookdeck, use the Hookdeck source URL as `channel.endpoint`, and paste the same value you sent as `X-Hub-Secret` into the athenahealth source. Without that header, notifications are unsigned and fail verification. A Hookdeck source holds one secret, so send the same `X-Hub-Secret` on every Subscription that points at it.

## Securing athenahealth webhooks

Read `X-Hub-Signature`, split it on the first `=` into a method and a hex signature, and check that the method is `sha256`. Compute an HMAC-SHA256 of the raw request body with your `X-Hub-Secret` value as the key, hex-encode it, and compare in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAthenaSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const i = signatureHeader.indexOf('=');
  if (i < 0) return false;
  const method = signatureHeader.slice(0, i).trim().toLowerCase();
  const signature = signatureHeader.slice(i + 1).trim().toLowerCase();
  if (method !== 'sha256') return false; // athenahealth may change the method; add support when it does

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signature, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/athenahealth', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.ATHENAHEALTH_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyAthenaSignature(req.body, req.get('x-hub-signature'), secret)) {
    return res.sendStatus(401);
  }

  const bundle = JSON.parse(req.body.toString('utf8'));
  // Queue bundle.entry[0].resource.notificationEvent and respond within 2 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_athena_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    method, sep, signature = signature_header.partition("=")
    if not sep or method.strip().lower() != "sha256":
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(
        signature.strip().lower().encode("utf-8"), expected.encode("utf-8")
    )

# In FastAPI: raw_body = await request.body()
# verify_athena_signature(raw_body, request.headers.get("x-hub-signature"),
#                         os.environ["ATHENAHEALTH_WEBHOOK_SECRET"])

```

Notifications use the FHIR content type, which `express.json()` doesn't parse by default. `express.raw({ type: '*/*' })` gives you the raw bytes whatever the content type.

To check your verifier, use the secret `athena-guide-test-secret` and this exact body:

```
{"resourceType":"Bundle","type":"history","entry":[]}

```

The header should be:

```
X-Hub-Signature: sha256=333f5ab4effec1c52890bdecead3a6ce95a3c753cd509f3e3904d20fb4a3e077

```

> **Make athenahealth webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Hub-Signature` header on every notification, answers well inside athenahealth's 2-second limit, and queues each Bundle until your service has fetched and processed the changed resources.

## athenahealth webhook limitations and pain points

### Two seconds to respond

**The Problem:** Deliveries are retried even though your service received them.

**Why It Happens:** athenahealth gives your endpoint 2 seconds to return a `2xx`, and says the limit "cannot be increased". A handler that calls the athenahealth API, writes to a database, or cold-starts can exceed it.

**Workarounds:**

* Verify, write the Bundle to a durable queue, and return `200`. athenahealth recommends this pattern and names Kafka, SQS, Pub/Sub and RabbitMQ as options.
* Fetch the changed resources from the queue consumer, not the webhook handler.

**How Hookdeck Can Help:** Hookdeck accepts each notification as soon as it arrives and responds within athenahealth's limit, then delivers to your service with its own timeout and [retries](/docs/retries), so slow processing no longer causes redeliveries.

### Unsigned notifications when the secret is missing

**The Problem:** Notifications arrive with no `X-Hub-Signature` header, and a verifying receiver rejects them all.

**Why It Happens:** `X-Hub-Secret` is optional when you create a Subscription. If you leave it out, athenahealth sends that Subscription's notifications unsigned.

**Workarounds:**

* Always send `X-Hub-Secret` when creating a Subscription.
* For a Subscription created without one, update it with `PUT` and a secret, or delete and recreate it.

**How Hookdeck Can Help:** Hookdeck marks unsigned notifications as failed verification and keeps them, so a Subscription created without a secret shows up straight away in the request log.

### A signing method that may change

**The Problem:** One day every notification could fail verification.

**Why It Happens:** athenahealth signs with `sha256` "at present" and says the method may change, so receivers should read it from the header. A verifier that only accepts `sha256` rejects any other method.

**Workarounds:**

* Parse the method from the header, log any value other than `sha256`, and alert on it.
* Watch the README in athenahealth's `aone-fhir-subscriptions` repository for changes.

**How Hookdeck Can Help:** Hookdeck's athenahealth source type verifies `sha256` today. If athenahealth changes the method, failed verifications show up in Hookdeck's request log, and the stored requests can be replayed once verification is updated.

### Conflicting retry windows

**The Problem:** You can't plan for how long athenahealth keeps trying after an outage.

**Why It Happens:** athenahealth's error-handling section says failures are retried for up to 1 hour and then held in a dead letter queue where they can be replayed for up to 7 days. Its performance section says failures and timeouts are retried periodically for up to 7 days.

**Workarounds:**

* Plan for the shorter window: treat anything longer than an hour of downtime as a reason to reconcile.
* Dead-lettered messages can be replayed for up to 7 days, but the README doesn't say how a replay is requested, so ask athenahealth before you need one. Re-read affected resources from the API in the meantime.

**How Hookdeck Can Help:** Hookdeck keeps accepting notifications while your service is down and holds them until it is back, so the length of athenahealth's retry window stops mattering.

### Batched, unordered, at-least-once delivery

**The Problem:** You process the same event twice, or process an older change after a newer one.

**Why It Happens:** One Bundle can carry several events, retries can repeat events, and athenahealth doesn't guarantee order across a distributed system.

**Workarounds:**

* Loop over `notificationEvent`, and deduplicate on each event's `id`.
* Fetch the current state of the `focus` resource rather than relying on the event order. Partition work by resource ID so two events for one resource aren't processed in parallel.

**How Hookdeck Can Help:** Hookdeck stores every Bundle, so you can inspect and replay exactly what athenahealth sent, and its [deduplication](/docs/deduplication) can drop repeated deliveries.

## Best practices

### Verify against the raw body

Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI. Parse the Bundle only after verification passes.

### Treat notifications as pointers

The payload is `id-only` by design, which keeps protected health information behind athenahealth's API access controls. Use `focus.reference` with the FHIR R4 API or `focus.identifier` with the proprietary API to fetch the resource. The README lists the endpoint to call for each resource.

### Route on the topic, then on each event

Take the topic from `entry[0].resource.topic`, then handle every entry in `notificationEvent`. All events in one Bundle share the same topic, because each Subscription covers one topic.

### Deduplicate on the event `id`

athenahealth delivers at least once. Store each `notificationEvent[].id` before processing and skip any you have seen. Two distinct events can still point at the same patient or appointment, so deduplicate on the event, not the resource. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep one secret per endpoint

Subscriptions are per topic and per practice, so a busy integration has many of them. Send the same `X-Hub-Secret` to every Subscription that shares an endpoint, or give each secret its own endpoint path.

### Acknowledge within 2 seconds

Verify, persist, return `200`, and do the API calls afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

athenahealth signs Event Subscription Platform notifications the WebSub way: `X-Hub-Signature: sha256=<hex>`, an HMAC-SHA256 of the raw body keyed with the `X-Hub-Secret` you send when creating each Subscription. Always send that secret, check the method in the header, answer within 2 seconds, deduplicate on each event's `id`, and fetch the current resource from the API rather than relying on order.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every athenahealth signature, answers inside the 2-second limit, and holds each notification until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle athenahealth webhooks reliably in minutes.