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:
{
"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 aSubscriptionStatus. Itstopicis a full SubscriptionTopic URL: take the part afterSubscriptionTopic/to getPatient.update.notificationEventcan hold several events of the same topic, and its length always equalseventsInNotification. Each event has a uniqueid, the database committimestamp, and afocuswith a FHIR reference, a proprietary identifier, or both.- Each event has a matching
AuditEvententry with the sameid. It records who triggered the event, and carries topic-specific extensions such as the patient, department or chart sharing group. - The top-level
timestampis when the notification was sent, not when the event happened. - athenahealth fills
eventsSinceSubscriptionStart,eventsInNotificationandeventNumberrelative 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.
- Generate a random secret, for example with
openssl rand -hex 32, and store it server-side asATHENAHEALTH_.WEBHOOK_ SECRET - Create a Subscription for each topic and practice, sending the secret in
X-Hub-Secret:
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.
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:
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 verifies the
X-Hub-Signatureheader 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, 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-Secretwhen creating a Subscription. - For a Subscription created without one, update it with
PUTand 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-subscriptionsrepository 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'sid. - Fetch the current state of the
focusresource 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 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.
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.
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 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 for free and handle athenahealth webhooks reliably in minutes.