Webhooks
Instead of polling, your system can receive signed webhook deliveries when things happen in the registry — enrollments, signed consents, submitted patient-reported outcomes, queries, exports, and study locks.
Registering an endpoint
Register an HTTPS endpoint via the webhooks resource (or by an administrator in the admin interface). Each registration gets a unique signing secret — treat it like a password and store it in your secrets manager. Deliveries are POSTs with a JSON body describing the event.
Events
| Event | Fires when |
|---|---|
participant.created | A participant was enrolled in a study. |
consent.signed | A participant completed consent; the signed document is available via the consent endpoints. |
epro.response.submitted | A participant submitted an ePRO instrument response. |
query.opened | A data query was raised against a record, awaiting site response. |
export.completed | An export job finished; the download URL is available for a limited time. |
study.locked | A study was locked against further data changes (for example at analysis time). |
Event bodies are small and reference-based: they carry the event type, event ID, study and resource identifiers, and a timestamp — not full payloads. Fetch the resource itself with your API token when you need the details.
Verifying signatures
Every delivery is signed with HMAC-SHA256 over the delivery timestamp and the exact request body, so you can confirm it came from BeaconCR and that the bytes were not altered in transit. Two headers carry what you need:
- Beacon-Signature: t=<unix-seconds>,v1=<hex digest> where v1 = HMAC_SHA256(secret, "<t>.<rawBody>")
- Beacon-Timestamp: the same <unix-seconds>, as a plain header for convenience
Verify before you trust: recompute the digest over the raw request body (not a re-serialized parse) and reject deliveries whose timestamp is older than 5 minutes so a captured request cannot be replayed.
import crypto from "node:crypto";
/**
* Verify a BeaconCR webhook delivery.
* rawBody must be the raw request body — not a re-serialized parsed object.
* The signature header carries the delivery timestamp and the HMAC-SHA256
* digest (hex) of "<timestamp>.<raw body>".
*/
export function verifyWebhookDelivery(
rawBody,
signatureHeader,
signingSecret,
maxAgeSeconds = 300
) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((pair) => pair.trim().split("="))
);
if (!parts.t || !parts.v1) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const provided = Buffer.from(parts.v1, "hex");
const matches =
provided.length === expected.length / 2 &&
crypto.timingSafeEqual(provided, Buffer.from(expected, "hex"));
// Reject stale deliveries so a captured request cannot be replayed later.
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= maxAgeSeconds;
return matches && fresh;
}Retry and backoff policy
If your endpoint does not return a 2xx response, the delivery is retried with exponential backoff:
| Attempt | Delay after previous attempt |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 12 hours |
That is 5 retries after the initial attempt (6 attempts total). After the final attempt fails, the delivery is marked failed and no further attempts are made. Endpoints failing continuously are marked failing; a subscription that keeps failing may be disabled and must be re-enabled via PATCH /webhooks/{id}. Design handlers to be idempotent: the same event may be delivered more than once (for example, if a response is lost after your handler has committed work) — deduplicate on the event ID.
Delivery log
Every delivery attempt — successful or not — is recorded and readable two ways: via the API at GET /api/v1/webhooks/{webhookId}/deliveries (attempt number, response status, error, latency, next retry) and in the admin interface (/admin/webhooks): event type, target URL, response status, attempt number, and timestamps. A missed or failing integration can be diagnosed without contacting support. Deliveries are not replayed automatically once the retry budget is exhausted; re-point a registration or trigger a manual retry to catch events the endpoint missed.
Endpoint requirements (SSRF policy)
In production, delivery targets must be public HTTPS endpoints: loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10), RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) and plain-HTTP URLs are refused. Non-production environments (development, staging test runs) allow local HTTP receivers so integrations can be exercised against a loopback test server; operators can override the production refusal with BEACON_WEBHOOK_ALLOW_PRIVATE=1 when an internal receiver is the intended target.
Best practices
- Respond 2xx quickly — do heavy work asynchronously after acknowledging the delivery.
- Verify the signature before processing the payload, and check the timestamp before acting.
- Do not reject deliveries on unknown event types — new event types may be added; ignore what you do not handle.
- Keep the endpoint reachable and monitor the delivery log for gaps.