Webhooks for document processing let a service tell your system when a document's status changes, instead of making your code ask over and over. The service sends a signed HTTP request to your endpoint for each event. Your receiver checks the signature and timestamp, drops duplicates, stores the event and answers quickly.
This is an educational guide. Schema Care offers no API, SDK, webhooks, FHIR output, 835 output or 837 output. The payloads, headers and code below are fictional examples written against https://api.example.com, the documents in them are fictional, and the receiver follows the open Standard Webhooks specification.
Job states in async document processing APIs
Async document processing APIs accept a document, return a job ID at once, and finish the work later. A job then moves through a small set of states: queued, processing, needs review, completed and failed. A webhook event fires on each change that matters, and the job ID ties every event back to the upload.
Accepting work now and finishing it later is what HTTP's 202 status is for. RFC 9110 on HTTP semantics says 202 Accepted means the request "has been accepted for processing, but the processing has not been completed," and that the response ought to describe the current status and point to a status monitor. For a document API, that monitor is a job resource the caller can fetch at any time.
| State | Meaning | Example event type | What the caller does |
|---|---|---|---|
| queued | Received, waiting for capacity | none, or document.queued | Nothing yet |
| processing | Being read | document.processing | Optional: show progress |
| needs_review | Some fields fell under the review threshold and wait for a person | document.needs_review | Hold the record; do not post |
| completed | All fields final, read or reviewed | document.completed | Fetch the result and post it |
| failed | Could not be read, such as an unreadable or password-protected file | document.failed | Log, alert, maybe resubmit |
The needs-review state is the one generic job models leave out, and it matters most in healthcare paperwork. A document can sit there for hours while a person checks a field. Callers that treat "not completed" as "still processing" will time out or resubmit. Schema Care's own flow shows why the state exists: fields under the customer's threshold wait in a review queue, where a person approves or corrects each one against the highlighted page, as the human review section describes.
Two rules keep states clean. Only move forward: a completed job never returns to processing. And make every state change an event with its own ID, so a receiver can tell a repeated delivery from a new change.
Should a document processing webhook payload carry the full result?
A document processing webhook payload should usually carry a pointer, not the full result. Send the event type, the job ID, the new state and a URL to fetch the data. The receiver then pulls the result over an authenticated API call. Small payloads are faster to verify, safer to log and easier to retry.
The Standard Webhooks specification calls this a thin payload, one that "will only include identifiers to the affected entities," and recommends keeping payloads small, usually under 20kb. A full extraction result for a 40-page record can be far larger.
Here is an example delivery for a fictional document:
POST /webhooks/documents HTTP/1.1
Host: receiver.example.com
Content-Type: application/json
webhook-id: msg_ex_7f3a91c2
webhook-timestamp: 1792074900
webhook-signature: v1,EXAMPLEsignatureBase64Value=
{
"type": "document.completed",
"timestamp": "2026-10-15T14:35:00Z",
"data": {
"job_id": "job_ex_0042",
"document_id": "doc_ex123",
"status": "completed",
"result_url": "https://api.example.com/v1/documents/doc_ex123/fields"
}
}
Health data adds a stronger reason to keep payloads thin. A webhook goes to whatever URL was configured, and copies of it end up in delivery logs, retry queues and error trackers on both sides. The HIPAA Privacy Rule's minimum necessary standard at 45 CFR 164.502 asks covered entities and business associates to make reasonable efforts to limit protected health information to the minimum necessary for the purpose. A payload with IDs and a status carries no patient name, member ID or diagnosis.
A full-result payload still has uses, such as a test endpoint that only ever sees fictional data. If you send full results, sign and send them the same way, and treat every system that stores the payload as holding the data. How data should be protected while it moves is covered in the guide to encryption at rest and in transit for PHI.
How should a webhook receiver verify the sender?
A webhook receiver verifies the sender by checking an HMAC signature over the message ID, the timestamp and the raw body, using a secret shared only with the sender. It rejects stale timestamps, compares signatures in constant time, and only then parses the JSON. A valid signature shows the message came from the secret's holder unchanged.
HMAC is defined in RFC 2104 on keyed hashing for message authentication. The RFC recommends a key at least as long as the hash output, which is 32 bytes for SHA-256. The Standard Webhooks specification uses HMAC-SHA256, signs the string msg_id.timestamp.payload, base64-encodes the result and prefixes it with v1. Its secrets are 24 to 64 random bytes, base64-encoded with a whsec_ prefix.
Here is a minimal example receiver in TypeScript for Node. It verifies the signature, rejects stale timestamps and stores each event ID to drop duplicates. The five-minute window is this example's choice; the specification asks for "some allowable tolerance" without fixing a number.
// Example receiver. Secret, store and queue are placeholders.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = Buffer.from(process.env.WEBHOOK_SECRET!.replace(/^whsec_/, ""), "base64");
const TOLERANCE_SECONDS = 5 * 60;
type Headers = Record<string, string | undefined>;
export async function handleWebhook(headers: Headers, rawBody: string, store: EventStore) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !ts || !signatures) return 400;
const sent = Number(ts);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sent) || Math.abs(now - sent) > TOLERANCE_SECONDS) return 400;
const expected = createHmac("sha256", secret).update(`${id}.${ts}.${rawBody}`).digest();
const valid = signatures.split(" ").some((entry) => {
const [version, value] = entry.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
if (!valid) return 401;
// Insert fails if this webhook-id was stored before: a retry, already handled.
const isNew = await store.insertIfAbsent(id, rawBody);
if (isNew) await store.enqueue(id); // process later, outside the request
return 200;
}
interface EventStore {
insertIfAbsent(id: string, body: string): Promise<boolean>;
enqueue(id: string): Promise<void>;
}
Four details in that code are easy to get wrong:
- Use the raw body. Parsing and re-serializing JSON changes spacing and key order, and the signature no longer matches.
- Compare in constant time. The specification says to use a constant-time comparison function, so response timing does not leak how much of a guess was right.
- Accept a list of signatures. The header can carry several space-separated signatures, which lets the sender rotate secrets without downtime.
- Answer fast. The specification suggests senders time out after 15 to 30 seconds. Store and queue, then do the real work elsewhere.
Signature checks cover the sender. The receiver also authenticates in the other direction when it fetches result_url, using its own API credentials. The HIPAA Security Rule's technical safeguards at 45 CFR 164.312 include a person or entity authentication standard: procedures to verify that whoever seeks access to electronic protected health information is the one claimed. Short-lived tokens scoped to reading results fit that call better than a long-lived key shared across systems. Record every fetch, since who accessed what is the subject of the guide to audit logs under HIPAA.
Webhook delivery failures and duplicate events
Webhook delivery is at-least-once in practice: a sender retries until it gets a success response, so the same event can arrive twice, and an event can also arrive late or never if every retry fails. A receiver must drop repeats by event ID and run a periodic check that catches anything missed.
The Standard Webhooks specification says it is up to the producer to retry until an attempt succeeds, and that a 2xx response counts as success. It recommends exponential backoff with jitter over a schedule spanning multiple days. Its example schedule goes from an immediate retry to gaps of 24 hours. The webhook-id stays the same on every retry, which is why the receiver above stores it.
Retention of those IDs needs thought. The specification suggests keeping IDs for about five minutes, which pairs with a five-minute timestamp window: a retry older than that fails the timestamp check anyway. But a retry sent hours later carries a fresh timestamp and a fresh signature with the same ID. If your store forgot the ID, the event runs twice. Keep IDs at least as long as the sender's longest retry schedule, or make processing itself safe to repeat, for example by checking whether the job's result is already posted.
Other failure cases to plan for:
| Case | What happens | What the receiver does |
|---|---|---|
| Duplicate delivery | Same webhook-id arrives again | Return 200 and skip processing |
| Out-of-order events | document.completed arrives before document.needs_review | Compare with the job's current state; never move a job backward |
| Endpoint down for hours | Sender retries, then may give up | Run a reconciliation job that lists recent jobs and fetches any you missed |
| Endpoint retired | Receiver returns 410 | The specification says the sender should disable the endpoint |
| Processing fails after the 200 | The event is stored but not handled | Retry from your own queue, not by asking the sender |
Retries mean a late copy of an old event can land after a newer one, so state checks matter. The job resource from the first section is the source of truth, and an event is only a hint to go look. For the upload side of the same problem, where a client retries a document submission, see the section on retries and idempotency keys for document uploads.
When to poll instead of using document webhooks
Polling beats document webhooks when your system cannot accept inbound requests, when volume is low, or while you test on a laptop. Instead of waiting for an event, the caller asks the job resource for its state on a schedule and backs off between calls. It is simpler to run, at the cost of delay and wasted requests.
Firewalls are the most common reason. Many clinic and hospital networks allow outbound HTTPS but no inbound connections from the internet. A webhook needs a public endpoint; polling only needs outbound calls. When documents arrive by file transfer instead of an API, the trade-offs shift again, as the guide to REST API vs SFTP for healthcare document intake discusses.
Low volume is the second reason. A billing team sending twenty documents a day gains little from webhook infrastructure. A poll every minute or two, stopping when the job reaches completed or failed, is enough. Remember that jobs in needs review can wait for hours, so poll those far less often.
Local testing is the third. A developer machine has no public URL, and tunneling tools add moving parts. Poll during development, and switch on webhooks in staging.
Poll politely: honor a 429 response and its Retry-After header, back off when there is none, and set a hard limit on how long you wait before raising an alert. The batch guide explains rate limits and Retry-After in more detail.
Many teams end up using both. Webhooks drive the fast path, and a slow poll or a nightly listing of recent jobs catches what webhooks missed. When hundreds of documents are in flight, that reconciliation step becomes the main safety net, and the guide to batch processing medical documents via API covers how to run it.