Guides
Webhooks
Register one HTTPS destination and SeekOut posts a signed job.completed event when a contact-retrieval job finishes, so you do not have to poll.
GET /v1/job-results/{job_id} read is the contract. A workspace that registers no endpoint loses nothing, and a workspace whose receiver is down loses nothing either — the result stays readable for seven days.Register an endpoint
One ACTIVE endpoint per workspace. A second one is 409 endpoint_limit_reached — disable or retire the first, or re-point it with PATCH. This needs the Webhooks access set on the key.
curl --request POST "https://developer-api.seekout.io/v1/webhook-endpoints" \
--header "Authorization: Bearer $SEEKOUT_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"url": "https://hooks.example.test/seekout"
}'HTTP/1.1 201 Created
{
"endpoint": {
"endpoint_id": "01JEXAMPLEENDPOINT00000000",
"url": "https://hooks.example.test/seekout",
"state": "active",
"created_at": "2026-09-09T12:00:00Z",
"updated_at": "2026-09-09T12:00:00Z"
},
"secret_id": "01JEXAMPLESECRET000000000",
"secret": "whsec_exampleexampleexampleexampleexample"
}The signing secret is shown once
secret appears in this response and in a rotation response, and nowhere else. SeekOut cannot show it to you again. Store it in your secret manager before you close the tab; if you lose it, rotate.
What arrives
One POST per finished job, Content-Type: application/json, and three headers: webhook-id, webhook-timestamp, and webhook-signature. They follow the Standard Webhooks convention, so an off-the-shelf verifier for it works here. webhook-id is the event's stable id — the same value on every retry and every replay — so deduplicate on it.
POST /your-receiver HTTP/1.1
Content-Type: application/json
webhook-id: 01JEXAMPLEDELIVERY00000000
webhook-timestamp: 1789041600
webhook-signature: v1,K5OYm1p8y0Zb3Qh9exampleexamplebase64signature=
{
"type": "job.completed",
"id": "01JEXAMPLEDELIVERY00000000",
"timestamp": "2026-09-09T12:00:00+00:00",
"data": {
"job_id": "01JEXAMPLEJOB0000000000000",
"type": "contact_retrieval",
"status": "partial",
"counts": {
"requested": 3, "delivered": 2, "not_found": 1,
"failed": 0, "reused": 0, "cancelled": 0, "pending": 0
},
"result_url": "/v1/job-results/01JEXAMPLEJOB0000000000000",
"result_expires_at": "2026-09-16T12:00:00Z"
}
}status is one of completed, partial, failed, cancelled, or expired. result_expires_at is present only when the job can actually carry a result. timestamp in the body is the event's FIRST emission, so the bytes are identical across every attempt.
Verify a signature
The signed content is {webhook-id}.{webhook-timestamp}.{raw body} — the raw request bytes, before any parse or re-serialization. The key is the base64 payload of the whsec_ secret.
import base64, hashlib, hmac
# `raw_body` is the exact request bytes, before any JSON parse.
signed = f"{webhook_id}.{webhook_timestamp}.".encode() + raw_body
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
# Accept ANY signature in the header: during a rotation there are two.
ok = any(
hmac.compare_digest(expected, candidate)
for candidate in header.split(" ")
if candidate
)- Compare in constant time. A plain
==on a signature leaks timing. - Reject a
webhook-timestampfar from your own clock — five minutes is the usual tolerance — so a captured request cannot be replayed at you later. - Accept ANY signature in the header, not only the first: during a rotation there are two, space-separated.
- Verify the bytes you received. Parsing the JSON and re-encoding it changes them and the signature will not match.
Rotate the secret
POST /v1/webhook-endpoints/{id}/rotate-secret mints a successor and shows it once. The predecessor keeps signing for a fixed 24 hours, so every delivery in that window carries BOTH signatures and a receiver still holding only the old secret keeps verifying. At most two secrets are live, so a second rotation inside the window is refused.
Deploy the new secret during the overlap, then let the old one lapse. The window is fixed and not caller-chosen: a leaked secret cannot be kept alive by asking.
Retries, dead-letter, and replay
Any 2xx is a success. Anything else — a 4xx, a 5xx, a redirect (never followed), a timeout, a TLS failure — is a failed attempt, retried after 5 s, 30 s, 2 m, 10 m, 30 m, 2 h, 6 h and 12 h with jitter that only ever delays. That is nine attempts over roughly 21 hours; after the last one the event is dead-lettered and stops.
GET /v1/webhook-deliverieslists attempts, newest first, filterable by endpoint, outcome, dead-letter state and event type.POST /v1/webhook-deliveries/{id}/replaysre-arms a dead-lettered attempt — bounded to 10 replays per endpoint per hour, then429webhook_replay_cap.- A retry and a replay NEVER re-run the job. No person is enriched again and no credit moves: the facts were fixed when the job ended, and delivery only carries them.
A late replay can outlive its result
Job results are kept for seven days and a dead-lettered event is not. Replaying an old delivery re-sends the same payload, result_expires_at and all — and if that time has passed, GET /v1/job-results/{job_id} answers 409 result_set_expired. Check result_expires_at before you act on a replayed event, and run the job again if you still need the values.
Destinations this platform refuses
A destination is checked when you register it AND against every DNS answer on every attempt, with the approved address pinned for that request. A refused destination is 422 webhook_url_not_allowed and persists nothing.
httpsonly. Plainhttpis refused.- A real, resolvable host, and no credentials in the URL.
- A globally routable address. Private, loopback, link-local, multicast and reserved ranges are refused — including the IPv6-mapped spellings of them.
- Redirects are never followed. A
3xxis a failed attempt, so point the endpoint at its final URL.
Managing endpoints
| Route | Note |
|---|---|
POST /v1/webhook-endpoints | One active endpoint per workspace; returns the secret once |
GET /v1/webhook-endpoints | Your own endpoints, newest first |
GET /v1/webhook-endpoints/{id} | One endpoint. Still readable after retirement; a foreign id is the same 404 an absent one is |
PATCH /v1/webhook-endpoints/{id} | Re-point, disable, or enable. It cannot retire |
DELETE /v1/webhook-endpoints/{id} | Retire, keeping the delivery history readable |
POST /v1/webhook-endpoints/{id}/rotate-secret | New secret once; the predecessor signs for 24 more hours |
GET /v1/webhook-deliveries | A page of attempts |
GET /v1/webhook-deliveries/{id} | One attempt |
POST /v1/webhook-deliveries/{id}/replays | Dead-lettered only; 429 webhook_replay_cap past the allowance |
Managing a destination is configuration, not metered work, so none of these charge. You can also read deliveries, rotate the secret, and replay a dead-lettered attempt from Webhooks in the console.