Webhooks
Webhooks
A webhook is a URL on your side that Protearc calls when something happens to one of your members' results. Each organization registers its own addresses and receives only its own events.
Register an endpoint
POST /v1/webhooks with {"url": "..."} registers an address and returns its id and signing secret (whsec_...). The secret is in that response only; store it like a password.
- The address must be a public
https://URL. It may not contain a username, a password or a fragment, and addresses that are private, loopback or link-local, or that use a local name, are refused with422. The local demo also acceptshttp://localhostandhttp://127.0.0.1. - An organization can have at most 10 endpoints. An eleventh is refused with
409(endpoint limit reached), and so is an address that is already registered (url already registered). - An endpoint receives events created after it was registered, not earlier ones.
GET /v1/webhooks lists your endpoints with the outcome of the last delivery (delivered or failed); it never returns a secret. DELETE /v1/webhooks/{webhook_id} removes one and answers 204.
curl -X POST http://localhost:8000/v1/webhooks \
-H "Authorization: Bearer ptk_sandbox_meridian_demo" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/protearc/hooks"}'import requests
r = requests.post(
"http://localhost:8000/v1/webhooks",
headers={"Authorization": "Bearer ptk_sandbox_meridian_demo"},
json={"url": "https://example.com/protearc/hooks"},
)
print(r.status_code, r.json()["id"])const r = await fetch("http://localhost:8000/v1/webhooks", {
method: "POST",
headers: {
Authorization: "Bearer ptk_sandbox_meridian_demo",
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://example.com/protearc/hooks" }),
});
console.log(r.status, (await r.json()).id);Events
| Event | Sent when | data.status |
|---|---|---|
result.released | The laboratory releases a result. | released |
qc.hold | The laboratory puts a result on a quality-control hold. | qc_hold |
A third type, webhook.test, is sent when an administrator presses the test button on the endpoint in Arc Watch, under Settings, then Integrations. Its data is empty.
The body is JSON. It carries ids and a status, never a name or a birth date. To read the results, call the reports route with data.member_ref.
The id identifies the event and stays the same across retries. Keep the ids you have handled and ignore a repeat.
{
"created_at": "2026-11-04T16:31:08.512345+00:00",
"data": {
"draw_number": 4,
"member_ref": "PA-0001",
"report_id": "3b6f0c9e-52c4-4a43-8f27-7d1f0a6e9c10",
"status": "released"
},
"id": "9c1d7a52-0e0b-4a4e-a4f5-2f6f9d1b7c33",
"type": "result.released"
}Headers
Every delivery is a POST with these headers.
| Header | Value |
|---|---|
Content-Type | application/json |
X-Protearc-Event | The event type, for example result.released. |
X-Protearc-Attempt | 1 for the first try, up to 4. |
X-Protearc-Timestamp | Unix time in seconds when this attempt was signed. It is new on each attempt. |
X-Protearc-Signature | sha256= followed by the hex digest described below. |
The signature
The signature is an HMAC-SHA256, keyed with your endpoint's secret, over the timestamp, a period and the raw request body, exactly as received:
Write it as sha256= plus the lowercase hex digest, and compare it with the header in constant time. Use the raw bytes of the body: parsing the JSON and writing it back out can change the bytes and break the match. Reject a request whose signature does not match.
Protearc does not check the age of a timestamp for you. To limit replays, also reject a request whose timestamp is more than a few minutes from your clock.
In a Node web framework, read the body as a Buffer (for example express.raw({ type: "application/json" })) before anything parses it.
HMAC_SHA256(secret, timestamp + "." + raw_body)import hashlib
import hmac
def verify(secret, timestamp, body, signature):
"""secret: str, timestamp: str (the header), body: bytes (raw), signature: str (the header)."""
signed = timestamp.encode() + b"." + body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), signature.encode())const crypto = require("node:crypto");
// secret: string, timestamp: string (the header), body: Buffer (raw), signature: string (the header)
function verify(secret, timestamp, body, signature) {
const expected = "sha256=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Retries and the delivery log
Answer with any 2xx status to acknowledge. Anything else, or no answer at all, counts as a failed attempt.
- A failed attempt is retried up to three times, after waits of 1, 2 and 4 seconds, so an event is tried at most four times. Each attempt is signed afresh with a new timestamp.
- Every attempt is logged with its number, the status code you returned or the error if none was received. The outcome for the event,
deliveredorfailed, is recorded per endpoint. - An event that failed all four attempts is not retried on its own. In Arc Watch, Settings, Integrations shows each address as healthy or failing with the number of undelivered events; Retry now queues them again.
- When an address starts failing, the administrators of your organization get one email. It names no member and no address, only that a webhook address is failing.
- Treat delivery as at least once and deduplicate on the event
id.
Rotate the secret
POST /v1/webhooks/{webhook_id}/rotate-secret returns a new secret, shown in that response only. The old secret stops signing at once: every delivery after the call is signed with the new one, so update your receiver with it straight away. An unknown webhook_id is 404.
curl -X POST http://localhost:8000/v1/webhooks/5d0c8a1e-6b1f-4f6e-9b0e-0c9a4d7f2b11/rotate-secret \
-H "Authorization: Bearer ptk_sandbox_meridian_demo"import requests
r = requests.post(
"http://localhost:8000/v1/webhooks/5d0c8a1e-6b1f-4f6e-9b0e-0c9a4d7f2b11/rotate-secret",
headers={"Authorization": "Bearer ptk_sandbox_meridian_demo"},
)
print(r.json()["secret"]) # shown only in this responseconst r = await fetch(
"http://localhost:8000/v1/webhooks/5d0c8a1e-6b1f-4f6e-9b0e-0c9a4d7f2b11/rotate-secret",
{ method: "POST", headers: { Authorization: "Bearer ptk_sandbox_meridian_demo" } },
);
console.log((await r.json()).secret); // shown only in this response