Skip to the documentation

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.

Product path
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://localhost is accepted only by a development deployment. To reach a laptop from a deployed tier, use a tunnel with an https URL.

What arrives

HeaderHolds
webhook-idThe event's id, the same on every retry and redelivery. Dedupe on it.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signaturev1, and a base64 HMAC-SHA256. During a secret rotation there are two, separated by a space; either one verifying is enough.
content-typeapplication/json
Body
{
  "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"
    }
  }
}
typeWhendata adds
match.completedA match finished with its shortlist.shortlistCount
match.failedA match could not finish.errorCode
match.canceledA match was stopped.
run.completedA headless run finished.shortlistCount
run.failedA headless run could not finish.errorCode
run.canceledA 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-timestamp more 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 2xx and nothing else.
  • Answer fast. Reply 2xx within 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.