Webhooks carry asynchronous work in two directions, and both directions ride the same signed-delivery contract. Inbound: a partner you have connected (a payment processor, an EMR vendor, a bank-data provider, a fax provider) POSTs an event to us, and we verify its signature before we act on the body. Outbound: for events the platform emits to an endpoint you register, we sign each delivery so your receiver can prove it came from us. Whichever way an event flows, an unverified delivery is rejected and nothing is processed.
This is the sandbox surface — real logic, zero live money and zero live patient data. Production access is a separate approval. Every receiver authenticates its caller before it touches a body: a delivery whose signature is absent or does not verify is a 401, and a receiver whose verification is not yet configured refuses with a typed 501/503 rather than trusting an unsigned event.
Three mechanisms carry asynchronous work. Knowing which is which keeps your integration from waiting on an event that will never arrive.
A connected partner POSTs an event to a receiver on /api/webhooks/*. Each receiver verifies the delivery signature before acting, then records the result durably.
For events the platform emits to an endpoint you register, each delivery carries an HMAC signature over the raw body so your receiver can verify it came from us.
For long jobs you read progress with a GET — e.g. bulk FHIR export via /api/fhir/bulk-status/[jobId]. You ask, we answer the current state.
Inbound and outbound webhooks share one verification contract. A delivery is signed over its exact raw bytes, and the receiver recomputes the signature and compares it in constant time before it reads the body.
Each delivery carries X-Shteg-Signature: a hex HMAC-SHA256 computed over the raw request body under the shared signing secret for that connection. The receiver reads the raw body (never a re-parsed, re-serialized copy), recomputes the HMAC, and compares the two values with a constant-time comparison so a forged signature cannot be discovered a byte at a time. A leading sha256= prefix on the header value is accepted and stripped.
X-Shteg-Signature is a 401 and the body is never processed. A receiver whose signing secret is not yet configured refuses with a typed 501/503 rather than trusting an unsigned event. Processing is idempotent on the delivery id, so a re-delivery never double-applies an event.Each connected partner category has a receiver. They differ only in the payload they carry — the verification contract is identical across all of them.
A connected partner POSTs an event; the receiver verifies its signature before acting. The example below is generic — every inbound receiver honors the same contract regardless of which partner category sent the event.
The sender computes an HMAC-SHA256 over the raw body under the shared signing secret and presents it in X-Shteg-Signature. The receiver reads the exact raw bytes, recomputes the HMAC, and compares in constant time. On a verified payment event the receiver marks the referenced PatientBill / Claim paid and posts a balanced double-entry transaction into the WORM ledger in integer cents; on a verified EMR event only demographics cross the bridge; on a verified transfer event the referenced transfer advances. These receivers are on the public edge allowlist because the signature — not a session — is the credential. Every API request is authenticated before it reaches a handler.
X-Shteg-Signature is a 401, and the body is never read. When the signing secret is not yet configured the receiver refuses with a typed 501/503 and processes nothing. Posting is idempotent on the delivery id, so a re-delivery never double-books.| Name | Type | In | Required | Description |
|---|---|---|---|---|
X-Shteg-Signature | string | header | Required | Hex HMAC-SHA256 of the raw body (a leading sha256= is accepted); compared in constant time. |
Content-Type | string | header | Required | application/json — verification runs over the exact raw bytes, which must not be re-serialized. |
(raw body) | object | body | Required | The partner event envelope. The raw bytes are read as text and signed as-is. |
# The sender signs the raw body under the shared signing key.
SIG=$(printf '%s' "$EVENT_BODY" \
| openssl dgst -sha256 -hmac "$SIGNING_KEY" -hex | sed 's/^.* //')
curl -X POST https://your-sandbox-origin.example/api/webhooks/payment \
-H "Content-Type: application/json" \
-H "X-Shteg-Signature: sha256=$SIG" \
--data-binary "$EVENT_BODY"import { createHmac } from "node:crypto";
const body = JSON.stringify(event);
const sig = createHmac("sha256", signingSecret)
.update(body)
.digest("hex");
await fetch("https://your-sandbox-origin.example/api/webhooks/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shteg-Signature": `sha256=${sig}`,
},
body, // verification runs over these exact bytes
});The fax receiver additionally accepts a GET reachability probe, verified the same way; a POST carries the delivered fax. Both are refused unless the signature verifies.
# GET — a reachability probe (verified the same way)
curl "https://your-sandbox-origin.example/api/webhooks/fax" \
-H "X-Shteg-Signature: sha256=$SIG"
# POST — a delivered inbound fax
curl -X POST https://your-sandbox-origin.example/api/webhooks/fax \
-H "Content-Type: application/json" \
-H "X-Shteg-Signature: sha256=$SIG" \
--data-binary "$EVENT_BODY"| Status | Meaning |
|---|---|
401 | Missing X-Shteg-Signature, or the HMAC did not match in constant-time comparison. |
501 | The signing secret for this receiver is not yet configured. Fail-closed — nothing is processed. |
503fail-closed | The receiver cannot verify its caller. Fail-closed — no unsigned event is trusted. |
For events the platform emits, we POST a signed delivery to the endpoint you register. Your receiver verifies our signature the same way ours verifies a partner's.
When you register a callback URL, each delivery carries X-Shteg-Signature: an HMAC-SHA256 over the raw body under your endpoint's signing secret, plus a X-Shteg-Delivery id for idempotency and a correlation id you can log. Verify the signature over the exact raw bytes before you trust the payload, and treat a repeated delivery id as the same event. The delivery envelope carries the event type, a timestamp, and typed error codes on any failed step.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDelivery(rawBody, header, signingSecret) {
const provided = String(header).replace(/^sha256=/, "");
const expected = createHmac("sha256", signingSecret)
.update(rawBody) // the exact raw bytes we sent
.digest("hex");
const a = Buffer.from(provided, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}For long-running operations you learn the result by reading its status. This is the same pattern the FHIR bulk export uses.
Kick off the long operation, keep the returned job id, then GET its status route on an interval until it reports complete. The state lives server-side and you read the current value.
# Read the current state of a long-running job by id.
curl "https://your-sandbox-origin.example/api/fhir/bulk-status/job_7c1e" \
--cookie "$SHTEG_SESSION"async function waitForJob(jobId) {
// Poll the status route until the job reports a terminal state.
for (;;) {
const res = await fetch(`/api/fhir/bulk-status/${jobId}`, {
credentials: "include",
});
const job = await res.json();
if (job.status === "completed" || job.status === "failed") return job;
await new Promise((r) => setTimeout(r, 5000));
}
}The bulk-export contract — how a job is started and what the status payload carries — is documented in full on FHIR · bulk data →
Inbound and outbound webhooks share one HMAC-SHA256 contract over the raw body, compared in constant time. Where a signing secret is not yet configured, the receiver returns a typed refusal (501/503) and processes nothing; a delivery whose signature does not verify is a 401. This is the sandbox surface — real logic, zero live money and zero live patient data. Production access is a separate approval.