Webhooks & signing
Add endpoints in the portal (Webhooks & events). Every delivery is signed:
Pryvc-Signature: t=1785170000,v1=5257a869e7…v1 = HMAC-SHA256(secret, "{t}.{raw_body}"). Reject signatures older than 5 minutes.
Failed deliveries retry with backoff (1m, 5m, 30m, 2h); replay any delivery from the portal.
Respond 2xx quickly — do the work async.
The envelope
Section titled “The envelope”Every delivery is a POST with this body. The shape is identical on the events feed, so one parser serves both.
{ "id": "1dc485ed-02c1-47b9-a44d-69b7c14bd7c2", "type": "certificate.revoked", "created_at": "2026-07-31T22:32:51.614Z", "share_id": null, "cert_id": "cc_q3a6goxdvyddqn2dpb", "data": null}share_id is set for share events, cert_id for certificate events — never both. data
carries a payload only where one exists, and it is encrypted under the affected share’s key,
so an event never leaks personal data to an endpoint that should not have it. When you need
the subject, fetch the record with your API key.
Signing
Section titled “Signing”Each request carries a Pryvc-Signature header over {timestamp}.{raw body}, HMAC-SHA-256,
hex-encoded, in the form t=<unix-seconds>,v1=<hex>.
Verify against the raw body bytes, before any JSON parsing — re-serializing changes the bytes and the signature will not match. Reject anything where the timestamp is more than 300 seconds from your clock; that window is what stops a captured request being replayed at you later.
Your endpoint’s secret is shown once when you create it, and can be revealed later by an
owner at GET /v1/webhooks/{id}/secret.
Delivery, retries, and failure
Section titled “Delivery, retries, and failure”A delivery is successful on any 2xx. Anything else — including a timeout, and the
timeout is 10 seconds — is a failure and will be retried.
Retries run on a fixed backoff, up to 5 attempts in total:
| Attempt | Sent after the previous failure |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After the fifth attempt the delivery is abandoned. Inspect what happened at
GET /v1/webhooks/{id}/deliveries, and force another attempt with
POST /v1/webhooks/deliveries/{deliveryId}/replay.
Design your endpoint to be idempotent. A retry means we did not observe your 2xx — not
that the event did not happen — so the same id can legitimately arrive more than once.
De-duplicate on it.
Return quickly. Do the work after you respond. An endpoint that does its processing before answering will hit the 10-second timeout and be retried, which usually means doing the work twice.
Managing endpoints
Section titled “Managing endpoints”POST /v1/webhooks |
Create one. Returns the secret — the only time it is shown in full. |
GET /v1/webhooks |
List them. |
GET /v1/webhooks/{id}/secret |
Reveal the secret. Owner role only, and the access is audited. |
POST /v1/webhooks/{id}/test |
Send a test delivery. |
GET /v1/webhooks/{id}/deliveries |
Recent attempts with response codes. |
POST /v1/webhooks/deliveries/{id}/replay |
Retry a specific delivery. |
DELETE /v1/webhooks/{id} |
Remove it. |
Testing and replay
Section titled “Testing and replay”Both of these fire an immediate outbound POST, so they share a limit of 60 calls per 10
minutes per user. Exceeding it returns 429 with Retry-After. It is set high enough that
ordinary debugging will not reach it.
Test deliveries
Section titled “Test deliveries”POST /v1/webhooks/{id}/test sends a signed test.ping to the endpoint and answers with the
result:
{ "delivered": true, "response_status": 200 }It is signed with your real secret, so it exercises your verification code for real. Two things it deliberately does not do:
- It does not create an event. A test is not something that happened to a consumer, and
the event stream is meant to be a truthful record — so
test.pingnever appears inGET /v1/eventsand never advances your cursor. Do not build logic that expects it there. - It does not appear in the delivery log. There is no delivery to log, because there is no event. The response above is the whole result.
Replaying a delivery
Section titled “Replaying a delivery”POST /v1/webhooks/deliveries/{id}/replay re-sends a real past event to the same endpoint with
a fresh signature — so the timestamp is current and it will pass your 5-minute replay
window, which a stored copy of the original request would not.
The attempt counter is not incremented. It counts scheduled retries against the limit of
five, and a manual replay does not consume one — you cannot exhaust a delivery’s retries by
debugging against it. A delivery that succeeds on replay flips to success, and its
delivered_at shows when that happened.
Neither action writes to your audit chain. That chain records consent events and is published through the public ledger — delivery activity belongs in the delivery log, which is where you will find it.
TypeScript / Node (WebCrypto)
Section titled “TypeScript / Node (WebCrypto)”import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPryvcSignature(secret: string, body: string, header: string): boolean { const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!match) return false; const [, t, sig] = match; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex'); return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));}
// Express: use the RAW body, not the parsed JSON.app.post('/webhooks/pryvc', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyPryvcSignature(process.env.PRYVC_WEBHOOK_SECRET!, req.body.toString(), req.header('Pryvc-Signature')!)) { return res.status(401).end(); } const event = JSON.parse(req.body.toString()); res.status(200).end(); // ack fast, process async});function verify_pryvc_signature(string $secret, string $body, string $header): bool { if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false; [, $t, $sig] = $m; if (abs(time() - (int)$t) > 300) return false; $expected = hash_hmac('sha256', "$t.$body", $secret); return hash_equals($expected, $sig);}
$body = file_get_contents('php://input');if (!verify_pryvc_signature($_ENV['PRYVC_WEBHOOK_SECRET'], $body, $_SERVER['HTTP_PRYVC_SIGNATURE'] ?? '')) { http_response_code(401); exit;}$event = json_decode($body, true);Python
Section titled “Python”import hashlib, hmac, re, time
def verify_pryvc_signature(secret: str, body: bytes, header: str) -> bool: m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "") if not m: return False t, sig = m.groups() if abs(time.time() - int(t)) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig)Endpoint rules
Section titled “Endpoint rules”https://only; no IP literals, localhost, or internal hostnames.- Signing secrets are encrypted at rest; owners can re-reveal them (each reveal is audited).
- Use the test button after deploying your handler — it sends a signed
test.ping.