Get told when a run finishes
Name a callback URL when you start a run, and one signed POST tells your server it ended. Verify it with any Standard Webhooks library.
Instead of polling, your server can be told. Name a callbackUrl when you start a run or a headless execution, and when it ends (complete, failed or canceled) we send one signed POST to that URL. The callback says what happened and where to read it; it never carries the shortlist, a client or an address. Your server reads those with its own key. Polling keeps working either way.
Make a signing secret
Every callback is signed with a secret that belongs to the API key that started the run. Make it in Settings → API keys, on that key. It starts with whsec_ and is shown once: keep it on your server, beside the key. A run that names a callback with a key that has no secret is refused with 422 no-signing-secret, before anything is spent.
Ask for a callback
On the product path, send notify.callbackUrl in the body of POST /api/v1/searches/{searchId}/runs. On the headless path, put callbackUrl in the request of POST /api/v1/search, or in the overrides of a saved search's run.
curl -X POST https://liviamatch.com/api/v1/searches/$SEARCH_ID/runs \
-H "Authorization: Bearer $LIVIA_API_KEY" \
-H "Idempotency-Key: run-$SEARCH_ID-1" \
-H "Content-Type: application/json" \
-d '{ "notify": { "callbackUrl": "https://hooks.example.com/livia" } }'- HTTPS only. Port 443, or 1024 and above. No user or password in the URL, and no fragment. At most 2000 characters. A query string is fine, if your receiver wants a token of its own there.
- Never inward. We resolve your host before every attempt and refuse it if any address it resolves to is private, loopback, link-local or reserved, then connect to the address we checked and no other. Redirects are not followed.
- Testing on a laptop.
http://localhostis accepted only by a development deployment. To reach a laptop from a deployed tier, use a tunnel with an https URL.
What arrives
| Header | Holds |
|---|---|
webhook-id | The event's id, the same on every retry and redelivery. Dedupe on it. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | v1, and a base64 HMAC-SHA256. During a secret rotation there are two, separated by a space; either one verifying is enough. |
content-type | application/json |
{
"type": "match.completed",
"id": "8a3f5d12-6b74-4c90-9e2a-0f8d1c6b3e57",
"occurredAt": "2026-10-12T15:04:05.000Z",
"data": {
"searchId": "7d2f8a40-b6c1-4e35-9a07-c4e1d8b3f592",
"executionId": "0d9e7c1a-52b8-4f3a-8e6d-1c7a9b2f4e83",
"matchId": "1e8b4c27-5a93-4d60-b2f7-9c0d3a6e8f15",
"briefId": "7d2f8a40-b6c1-4e35-9a07-c4e1d8b3f592",
"status": "complete",
"shortlistCount": 4,
"links": {
"run": "https://liviamatch.com/api/v1/search/7d2f8a40-b6c1-4e35-9a07-c4e1d8b3f592/executions/0d9e7c1a-52b8-4f3a-8e6d-1c7a9b2f4e83",
"match": "https://liviamatch.com/api/v1/matches/1e8b4c27-5a93-4d60-b2f7-9c0d3a6e8f15"
}
}
}| type | When | data adds |
|---|---|---|
match.completed | A match finished with its shortlist. | shortlistCount |
match.failed | A match could not finish. | errorCode |
match.canceled | A match was stopped. | |
run.completed | A headless run finished. | shortlistCount |
run.failed | A headless run could not finish. | errorCode |
run.canceled | A headless run was canceled. |
Event types and payloads keep their v1 names, because a delivery is signed and stored and a redelivery resends the same bytes. So a match.* event is about a run of a search, a run.* event about a headless execution, and links.match points at the old /matches path, which still answers (old words and new).
status is the resource's own word: a match is complete, a run succeeded. A data set match has no run, so it carries no searchId, executionId or links.run. A completed event is sent once the result is final, after its Fair Housing review.
Verify it
Callbacks follow Standard Webhooks, so verifying is one call in the standardwebhooks library for Node (npm) or Python (PyPI). Verify the raw body, before parsing it: the signature covers its exact bytes.
import express from 'express'
import { Webhook } from 'standardwebhooks'
const webhook = new Webhook(process.env.LIVIA_WEBHOOK_SECRET) // whsec_…
const app = express()
// Keep the raw body: the signature covers its exact bytes.
app.post('/livia', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
// Throws on a bad signature, or a timestamp more than 5 minutes from your clock.
event = webhook.verify(req.body.toString('utf8'), req.headers)
} catch {
return res.status(400).end()
}
if (await alreadyHandled(event.id)) return res.status(204).end() // event.id is webhook-id
res.status(204).end() // answer fast, then do the work
const link = event.data.links.match ?? event.data.links.run
const read = await fetch(link, { headers: { Authorization: `Bearer ${process.env.LIVIA_API_KEY}` } })
await handle(event.type, (await read.json()).data)
})- Five minutes. The libraries refuse a
webhook-timestampmore than five minutes from your clock, so a captured callback cannot be replayed later. Keep your server's clock synced. - Dedupe on `webhook-id`. A callback can arrive twice (a retry after your answer was lost, a redelivery). Remember the ids you handled and answer a repeat with
2xxand nothing else. - Answer fast. Reply
2xxwithin 10 seconds, then do the work. We read none of your answer but its status.
When your server is down
A 2xx delivers the callback. A 410 Gone stops it for good. Anything else, a timeout or a refused address is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 attempts in all, and then the callback is dead. A callback also stops when the key that started the run is revoked or expires.
GET /api/v1/webhook-deliveries shows each callback, its attempts and your server's last answer, and POST /api/v1/webhook-deliveries/{deliveryId}/redeliver sends a finished one again under the same webhook-id. Settings → API keys shows the newest ones too.
Rotate the secret
Rotate it in Settings → API keys. The new secret signs from that moment, and the old one keeps signing beside it for 24 hours, so every callback in that window carries both signatures. Swap the secret on your server inside the window and nothing fails to verify.
Why not OIDC
A signed JWT in Authorization would have your server fetch our keys, cache them, and check issuer, audience and expiry, and would sign its own claims rather than the body. A Standard Webhooks signature binds the exact body to your key's own secret, and is one call in a library you can read.