Skip to Content

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

  1. Open Settings → Integrations → Webhook delivery and add an endpoint.
  2. Enter the endpoint’s HTTPS URL.
  3. 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.
  4. Choose the applications it covers: all of them, or only the ones you pick.
  5. 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.

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 the webhook-id header. For review events, order by caseVersion, then eventSequence.
  • 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 answersWhat happens
Any 2xx within 10 secondsDelivered.
408, 425, 429, 401, 403, or any 5xxTried again later.
No answer within 10 seconds, or no connectionTried again later.
410 GoneThe event fails and VeriProof turns the endpoint off.
Any other 3xx or 4xxThe 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.created or review.case.assignment_changed event, 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.

HeaderWhat it holds
webhook-idThe 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-timestampWhen this attempt was signed, in Unix seconds.
webhook-signatureOne or more signatures separated by spaces, each written as {version},{base64 signature}.
webhook-key-versionThe 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

VersionSchemeYou verify with
v1aed25519Public keys that your VeriProof deployment publishes at /.well-known/veriproof-webhook-keys.json on its Ingest API and Customer Portal API
v1HMAC-SHA256A 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:

  1. Reject it if webhook-id, webhook-timestamp, or webhook-signature is missing.
  2. Reject it if webhook-timestamp is more than 300 seconds from your clock, in either direction.
  3. Accept it if any listed signature matches any key you trust. Skip signature versions you don’t use. Compare HMAC values in constant time.
  4. Skip it if you’ve already processed that webhook-id.
  5. 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.
Last updated on