Webhooks
A webhook sends a signed message to your HTTPS endpoint when something happens in VeriProof. Teams use them to:
- follow review work in their own dashboards, ticket queues, or on-call tools;
- send reviewer notifications themselves, through a channel their reviewers already watch; and
- react to session, health, and compliance changes.
Each endpoint you add is a subscription: one URL, the events you chose, the applications it covers, and a signing method.
A webhook tells you that something changed. It never records a decision. Reviewers decide in VeriProof, and neither your endpoint’s answer, a ticket status, nor an email reply counts as one. To hand an outcome back to paused work in your application, use the outcome callback.
Before you start
You need:
- the Manage Integrations permission. Administrators have it by default, and a custom role can include it; and
- an HTTPS endpoint on the public internet that can check signatures and store events before it answers.
Set up an endpoint
Add the endpoint
- Open Settings → Integrations → Webhook delivery and add an endpoint.
- Enter the endpoint’s HTTPS URL.
- Choose the events it should receive. Review events are listed in the review event reference. You can add session, health, and compliance events to the same endpoint.
- Choose the applications it covers: all of them, or only the ones you pick.
- Choose how VeriProof signs requests. See Choosing a scheme.
Every endpoint also receives a few events about the subscription itself, such as verification requests. You don’t select those.
If you chose a shared secret, copy the whsec_ secret now and put it in your
secret manager. VeriProof shows it only once.
Answer the verification challenge
Before any events flow, VeriProof sends a signed
subscription.verification_requested event with a random challenge:
{
"id": "evt_0192a3f4c5d67e8f9a0b1c2d3e4f5a6b",
"type": "subscription.verification_requested",
"schemaVersion": 1,
"time": "2026-09-30T14:02:11.482+00:00",
"tenantId": "3f2b8c1e-7d4a-4e9b-9c61-2a5d0e8f7b13",
"subscriptionId": "a41c7e92-5b3d-4f08-8e6a-1d9c2b7f0e54",
"challenge": "q3Xv9mT0sKc2LwYp8RzN4hJd6FbE1uGa7iOe5tMnWxQ"
}Your endpoint verifies the signature, then answers with any 2xx status within 10 seconds and this JSON body:
{ "challenge": "q3Xv9mT0sKc2LwYp8RzN4hJd6FbE1uGa7iOe5tMnWxQ" }The endpoint shows Verifying until the answer arrives. A 2xx answer
without the matching challenge fails right away. If your endpoint isn’t
ready yet, VeriProof keeps trying for about 15 minutes, then marks
verification as failed. Fix the receiver and send the verification again from
the endpoint’s details.
VeriProof asks for verification again whenever the URL changes, the signing method changes, or you turn the endpoint back on after VeriProof turned it off.
Build the receiver
The VeriProof SDKs check signatures for you. Pass the raw request bytes, the
headers, and your VeriProof endpoint (for ed25519) or your whsec_ secret (for
HMAC). A Standard Webhooks
library that supports your scheme works too.
TypeScript
import express from 'express';
import {
RemoteWebhookKeySet,
WebhookVerificationError,
verifyWebhook,
type VerifiedWebhook,
} from '@veriproof/sdk-core';
// VERIPROOF_ENDPOINT is the ingest URL your SDK already uses, such as https://ingest.veriproof.app
const keys = new RemoteWebhookKeySet({ apiBaseUrl: process.env.VERIPROOF_ENDPOINT! });
const app = express();
app.post('/veriproof/events', express.raw({ type: 'application/json' }), async (req, res) => {
let verified: VerifiedWebhook;
try {
verified = await verifyWebhook(
req.body,
{ getAll: (name) => [req.headers[name] ?? []].flat() },
{ keys }, // For a shared-secret endpoint: { secrets: [process.env.VERIPROOF_WEBHOOK_SECRET!] }
);
} catch (error) {
const retryLater = error instanceof WebhookVerificationError && error.code === 'key_set_unavailable';
return res.sendStatus(retryLater ? 503 : 401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'subscription.verification_requested') {
return res.json({ challenge: event.challenge });
}
await saveEventOnce(verified.id, event); // Your durable store. Ignore an id you've already saved.
res.sendStatus(204);
});When the key set can’t be loaded, answer 503. It tells VeriProof, and your
own monitoring, that the problem is temporary, and VeriProof tries again later.
How delivery works
VeriProof records each event with the change that caused it, then sends it from a separate delivery queue, usually within a minute. A slow or unavailable endpoint never holds up reviewers or your application.
- At least once, in any order. The same event can arrive more than once,
and events can arrive out of order. De-duplicate by the event
id, which is also thewebhook-idheader. For review events, order bycaseVersion, theneventSequence. - Answer fast, work later. A 2xx answer within 10 seconds means you’ve accepted the event. Store it first, then answer, then do the slower work.
- A few at a time. VeriProof sends up to four events at once to each endpoint, so one busy endpoint doesn’t slow the others.
What VeriProof does with your answer:
| Your endpoint answers | What happens |
|---|---|
| Any 2xx within 10 seconds | Delivered. |
408, 425, 429, 401, 403, or any 5xx | Tried again later. |
| No answer within 10 seconds, or no connection | Tried again later. |
410 Gone | The event fails and VeriProof turns the endpoint off. |
| Any other 3xx or 4xx | The event fails without another try. Redirects aren’t followed. |
Retries back off with some randomness: the first comes after about 30 seconds,
and the gap grows to at most 12 hours. If you send Retry-After, VeriProof
waits at least that long. An event keeps trying for up to three days.
When deliveries fail
Check delivery history
Each endpoint has a delivery history that shows every event, its status, the HTTP status your endpoint returned, and when VeriProof tries next. VeriProof doesn’t keep your endpoint’s response bodies.
To confirm the whole path works, send a test event from the endpoint. It uses
the webhook.test type and has no effect on review work.
Send a failed event again
Once you’ve fixed the endpoint, open the failed delivery and send it again. It goes out with its original event ID, so your de-duplication still works, and it gets a fresh three-day retry window. You can do this while the endpoint is on and verified.
When VeriProof turns an endpoint off
VeriProof turns an endpoint off when:
- it answers
410 Gone; or - one event keeps failing for the whole three-day window.
Administrators get a notice either way, and events still waiting for that
endpoint are marked failed. When the three-day window is what ran out,
VeriProof also sends a subscription.disabled event, so an endpoint that
recovers can record why it went quiet.
Turn an endpoint back on
When you turn it back on, VeriProof asks for verification again. Once it passes,
the endpoint receives a subscription.events_missed event with the window it
was off (missedFrom to missedUntil).
To catch up, send failed events again from delivery history, or list them with the reconciliation read. VeriProof doesn’t queue events for an endpoint while it’s off, so check review work from the missed window in VeriProof.
If you turned the endpoint off yourself and it was already verified, it starts
receiving right away. You still get subscription.events_missed.
Send reviewer notifications yourself
By default, VeriProof notifies the assigned reviewer by email, and in Microsoft Teams or Slack when you’ve connected them. If you’d rather notify reviewers through your own tools, you can take that over for one application at a time.
To turn it on, open Applications, select the application, and go to
Config → Human review → Reviewer notifications. You need the Manage
Integrations permission and a verified, active endpoint that covers the
application and includes both review.case.created and
review.case.assignment_changed.
While it’s on:
- VeriProof stops emailing and messaging the assigned reviewer.
- The Inbox, due dates, escalation, and Administrator fallback keep working as usual.
- If your endpoint can’t receive a
review.case.createdorreview.case.assignment_changedevent, because the delivery failed or the endpoint was turned off, VeriProof emails the assigned reviewer itself and tells Administrators. - If no verified endpoint covers the application when a reviewer needs to hear about work, VeriProof notifies them as usual.
- Each review’s audit trail shows that your organization owned the notification, plus any fallback email VeriProof sent. Auditors can see why there’s no VeriProof delivery record.
Turning this on or off is recorded in the audit log.
How VeriProof signs requests
Every HTTP request VeriProof sends to one of your endpoints is signed the same way, following the Standard Webhooks specification. That covers outcome callbacks, their safe test, and event webhooks. A Standard Webhooks library can verify VeriProof requests if it supports the signature scheme you use.
| Header | What it holds |
|---|---|
webhook-id | The message ID. It stays the same on every retry, so use it to spot duplicates. Outcome callbacks use the delivery ID; event webhooks use the event ID. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | One or more signatures separated by spaces, each written as {version},{base64 signature}. |
webhook-key-version | The IDs of the keys that signed, in the same order as the signatures. It’s there to help with troubleshooting. Don’t use it to pick a key. |
The signature covers this exact content, with the body as the raw bytes you received:
{webhook-id}.{webhook-timestamp}.{raw request body}Signature schemes
| Version | Scheme | You verify with |
|---|---|---|
v1a | ed25519 | Public keys that your VeriProof deployment publishes at /.well-known/veriproof-webhook-keys.json on its Ingest API and Customer Portal API |
v1 | HMAC-SHA256 | A whsec_ secret that belongs to one webhook |
Outcome callbacks and their safe test always use v1a. There’s no callback
secret to store.
Each event webhook has a signatureScheme of ed25519 (the default) or
hmac-sha256. With hmac-sha256, VeriProof shows the webhook’s secret once,
when it’s issued. The secret is whsec_ followed by base64. The HMAC key is the
decoded bytes after the prefix, not the text itself.
Choosing a scheme
Pick ed25519 unless your receiver can’t verify it. Your side holds only public
keys, so there’s nothing to leak, and anyone with the key set, such as your
security team or an auditor, can confirm a request came from VeriProof.
Pick hmac-sha256 when the receiving tool only supports shared secrets, as some
low-code platforms do. Keep the secret in a secret manager, never in source
code or logs.
Verify a request
The SDK receivers above follow these rules. If you write your own, check every request in this order:
- Reject it if
webhook-id,webhook-timestamp, orwebhook-signatureis missing. - Reject it if
webhook-timestampis more than 300 seconds from your clock, in either direction. - Accept it if any listed signature matches any key you trust. Skip signature versions you don’t use. Compare HMAC values in constant time.
- Skip it if you’ve already processed that
webhook-id. - Only then parse the body.
Read the body as raw bytes. If your framework parses JSON first, the bytes can
change and the signature won’t match. In Express, use express.raw({ type: 'application/json' }) on the receiving route.
These Node.js examples use only the built-in crypto module. The first part
applies to both schemes:
import { createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto';
// Rules 1 and 2: required headers and the five-minute window.
function signedParts(headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (!id || !/^\d+$/.test(timestamp ?? '') || !signature) return null;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return null;
return {
id,
content: Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]),
signatures: signature.split(' ').map((entry) => entry.split(',')).filter(([, value]) => value),
};
}Verify v1a with the published keys
Load the key set once and cache it for up to an hour. If no key matches a request, fetch it again, but no more than once a minute. If your receiver has no outbound network access, pin a copy of the key set JSON in its configuration instead.
const keySetUrl = 'https://<your-veriproof-api-host>/.well-known/veriproof-webhook-keys.json';
const { keys } = await (await fetch(keySetUrl)).json();
const publicKeys = keys.map((jwk) => createPublicKey({ key: jwk, format: 'jwk' }));
function verifiedByVeriProofKey(parts) {
return parts.signatures.some(([version, value]) =>
version === 'v1a' &&
publicKeys.some((key) => verify(null, parts.content, key, Buffer.from(value, 'base64'))));
}Verify v1 with a webhook secret
const secret = Buffer.from(process.env.VERIPROOF_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
function verifiedBySecret(parts) {
const expected = createHmac('sha256', secret).update(parts.content).digest();
return parts.signatures.some(([version, value]) => {
if (version !== 'v1') return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}Put it together
const parts = signedParts(req.headers, req.body);
// For an hmac-sha256 webhook, call verifiedBySecret(parts) instead.
if (!parts || !verifiedByVeriProofKey(parts)) return res.status(401).end();
if (await alreadyProcessed(parts.id)) return res.status(200).end();
const event = JSON.parse(req.body);For an outcome callback, use the VeriProof SDK receiver instead. It applies these rules and the rest of the callback contract for you. See the Outcome callback reference.
Key and secret rotation
VeriProof keys. When your deployment rotates its signing key, the new public
key appears in the key set at least an hour before VeriProof signs with it.
During the changeover, requests carry two v1a signatures, one per key. If you
verify against the whole key set and refresh it as described above, you don’t
need to change anything.
Webhook secrets. When you rotate an hmac-sha256 webhook’s secret, the
previous secret keeps signing for 24 hours alongside the new one, so each
request carries two v1 signatures. VeriProof sends a
subscription.signing_key_rotating event whose previousSecretValidUntil shows
when the old secret stops; the webhook’s rotationOverlapEndsAt shows the same
time. Put the new secret in your receiver before then. The SDK verifiers accept
a list of secrets, so you can pass both during the overlap.
Changing a webhook’s scheme has no overlap. Switching to hmac-sha256 issues a
new secret right away, and switching to ed25519 removes the secret. Update
your receiver to accept the new scheme first; the endpoint has to pass
verification again either way.
Security
- HTTPS to public addresses only. VeriProof refuses private, link-local, and cloud metadata addresses, checks the address again when it connects, and never follows redirects.
- Small payloads. Events carry IDs, versions, dates, and short fixed categories. Prompts, model output, tool payloads, reviewer comments, and personal data stay in VeriProof. Fetch them from the case URL with an application key when your integration is allowed to see them.
- Verify everything. Check the signature on every request, including verification and test events, before you read the body.
- Guard the secret. Keep a
whsec_secret in a secret manager, out of source code, logs, and screenshots. Rotate it when the people or systems that hold it change. - Keep access narrow. Give Manage Integrations only to people who own your integrations.
Related
- Review event reference: every event type, its fields, and example bodies.
- Outcome callback reference: return an outcome to paused work.
- Key management: how VeriProof protects signing keys and webhook secrets.