Skip to content
SeekOutDevelopers

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.

Delivery is a convenience, not the contractThe authenticated 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.

Register a destination
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.

The payload carries no personal dataNo person ids, no contact values, no receipt ids — a state word, seven counts, and a URL you must call with your own credential. A receiver's logs and error trackers are outside this platform's control, so nothing sensitive is put in them.

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.

Verify one delivery
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
)

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.

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.

Managing endpoints

Every route in this family is free
RouteNote
POST /v1/webhook-endpointsOne active endpoint per workspace; returns the secret once
GET /v1/webhook-endpointsYour 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-secretNew secret once; the predecessor signs for 24 more hours
GET /v1/webhook-deliveriesA page of attempts
GET /v1/webhook-deliveries/{id}One attempt
POST /v1/webhook-deliveries/{id}/replaysDead-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.