Set up an endpoint
- Open Settings > API and webhooks as an owner and add an endpoint: its HTTPS address and the events it should receive.
- Copy the signing secret (it starts with whsec_). It is shown once.
- Press Send test. A ping event arrives, signed like every other event.
You can do the same from code with POST /webhooks and a key that has webhooks:manage. POST /webhooks
Event types
Choose the events per endpoint. New event types may be added; ignore types you do not handle.
| Event | When it is sent |
|---|---|
application.created | A new application, from any source: careers site, embed, API, jobs inbox or referral |
application.stage_changed | An application moved between stages, by a person or an auto-advance rule |
interview.completed | The candidate finished an AI interview |
scorecard.ready | The AI scorecard finished, with every citation checked |
decision.made | A person recorded advance, hold or reject |
decision.undone | A person undid a decision inside its five minute undo window |
offer.sent | An approved offer went to the candidate |
offer.signed | The candidate signed the offer |
offer.declined | The candidate declined the offer, with a reason code |
document.sent | An agreement or contract went out for signature |
document.signed | One party signed a document; complete is true once everyone has |
document.declined | A party declined a document, which closes it |
candidate.data_request | The candidate asked to see or delete their data or for a human review. If you hold a copy, you have duties too |
ping | Sent only to one endpoint when you press Send test or call the test endpoint. |
Payload
Every event has the same envelope. data holds ids and a short summary, not full personal data; fetch the resource with the API when you need more.
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "application.stage_changed",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": { ... }
}Headers
Each delivery carries these headers:
Content-Type: application/json
User-Agent: SeekIQ-Webhooks/1.0 (+https://seekiq.io/developers/webhooks)
SeekIQ-Signature: t=1760779800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
SeekIQ-Event-Id: evt_01JB7Y2K9T3V4W5X6Y7Z8A9B
SeekIQ-Event-Type: application.stage_changed
SeekIQ-Delivery-Id: 0193f1c2-7a4e-7b1d-9c3e-5f8a2b6d4e10SeekIQ-Signature | Timestamp and one or two HMAC signatures |
SeekIQ-Event-Id | The event, the same on every retry |
SeekIQ-Event-Type | The event type, also in the body |
SeekIQ-Delivery-Id | This attempt; a retry gets a new one |
Verify the signature
The signature is a hex HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with the endpoint secret. Compute it over the exact bytes you received before any JSON parsing, then compare in constant time. Reject a timestamp more than five minutes from your clock.
Node.js
import crypto from 'node:crypto';
/** True when one of the v1 signatures matches and the timestamp is recent. */
export function verifySeekIQSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
const parts = header.split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || sigs.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return sigs.some((s) => {
const got = Buffer.from(s, 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}import express from 'express';
const app = express();
const seen = new Set(); // use your database in production
// Read the raw body: the signature covers the exact bytes SeekIQ sent.
app.post('/seekiq/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifySeekIQSignature(raw, req.get('SeekIQ-Signature'), process.env.SEEKIQ_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
if (!seen.has(event.id)) {
seen.add(event.id);
// queue your work here, then answer quickly
}
res.sendStatus(200);
});Python
import hashlib
import hmac
import time
def verify_seekiq_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""True when one of the v1 signatures matches and the timestamp is recent."""
if not header:
return False
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), "")
sigs = [v for k, v in parts if k == "v1"]
if not t.isdigit() or not sigs:
return False
if abs(int(time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/seekiq/webhooks")
def seekiq_webhook():
raw = request.get_data() # the exact bytes, before JSON parsing
if not verify_seekiq_signature(raw, request.headers.get("SeekIQ-Signature", ""),
os.environ["SEEKIQ_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# skip event["id"] if you have handled it, otherwise queue the work
return "", 200Secret rotation
Rotating a secret (in Settings or with the rotate-secret endpoint) keeps the old one working for 24 hours. During that time the header carries two v1 values, the new secret first. Accept the event if either matches.
Retries and backoff
A delivery succeeds on any 2xx within 10 seconds. Anything else, including a redirect, a timeout or a refused connection, is retried.
- Retry schedule
- After 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, each give or take 10 percent: 8 attempts over about two days.
After 3 days of failures in a row the endpoint is turned off and workspace owners get an email. Turn it back on in Settings or with PATCH active: true.
Answer quickly: store the event, return 200 and do the work in the background.
Idempotency
The same event can arrive more than once, for example when your 200 was lost on the way back. Every attempt carries the same SeekIQ-Event-Id and body, with a new SeekIQ-Delivery-Id. Store the event id and skip one you have already handled.
Ordering
Events are not guaranteed to arrive in order, and retries can overtake newer events. Use created_at on the event and updated_at on the resource, or fetch the current state with the API.
Testing deliveries
Send test posts a ping event to one endpoint. The delivery log in Settings shows every attempt with its status code, time taken, the first 1 KB of your answer and the next retry, and any delivery can be sent again. The same is available from code:
# Send a ping to one endpoint
curl -X POST "https://app.seekiq.io/api/v1/webhooks/whk_01JBCJ7W/test" \
-H "Authorization: Bearer $SEEKIQ_API_KEY"
# Read the delivery log (pending, succeeded or failed)
curl "https://app.seekiq.io/api/v1/webhooks/whk_01JBCJ7W/deliveries?status=failed" \
-H "Authorization: Bearer $SEEKIQ_API_KEY"
# Send one delivery again
curl -X POST "https://app.seekiq.io/api/v1/webhooks/whk_01JBCJ7W/deliveries/dlv_01JBDK8X/redeliver" \
-H "Authorization: Bearer $SEEKIQ_API_KEY"A redelivery is one attempt with a new delivery id and is not retried automatically.
Endpoint addresses
Endpoint addresses must use https and reach the public internet. Private, loopback and other reserved addresses are refused when you save and checked again before each delivery. Redirects are not followed. For local work, use a tunnel with a public HTTPS address.
Zapier, Make and n8n
There is no SeekIQ app in the Zapier or Make directories yet. Their catch hooks work today:
- Create a catch hook: Webhooks by Zapier with Catch Hook, a Custom webhook in Make or a Webhook node in n8n.
- Add its address as an endpoint in Settings > API and webhooks and pick the events.
- The tool sees type and data as fields. Check SeekIQ-Signature if the tool can run code; otherwise keep the hook address private, as it is the only secret.
- For actions, use the tool's HTTP step with a service key that has only the scopes the flow needs, and an Idempotency-Key on writes.
Slack and Microsoft Teams messages need no tool: Settings > Integrations posts them directly.
Event payloads
The data each event carries. The sample values are made up; field names and types come from the OpenAPI file.
ping
Sent only to one endpoint when you press Send test or call the test endpoint.
- messagestringRequired
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "ping",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"message": "Hello from SeekIQ"
}
}application.created
A new application, from any source: careers site, embed, API, jobs inbox or referral
- application_idIdRequired
- job_idIdRequired
- candidate_idIdRequired
careers_siteembedapiemail_inboxreferralsourcedjob_board- originstring | nullRequired
Host origin for embed applications
The apply form answers, as on the Application.
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "application.created",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"application_id": "app_01JB7X4M2Q8Z",
"job_id": "job_01JB7X4M2Q8Z",
"candidate_id": "cand_01JB7X4M2Q8Z",
"source": "careers_site",
"origin": "string",
"answers": [
{
"question_id": "string",
"label": {},
"type": "string",
"value": "string"
}
]
}
}application.stage_changed
An application moved between stages, by a person or an auto-advance rule
- application_idIdRequired
- job_idIdRequired
- reasonstring | nullRequired
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "application.stage_changed",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"application_id": "app_01JB7X4M2Q8Z",
"job_id": "job_01JB7X4M2Q8Z",
"from_stage": {
"id": "stg_01JB7X4M2Q8Z",
"type": "applied",
"name": "Phone screen",
"order": 1
},
"to_stage": {
"id": "stg_01JB7X4M2Q8Z",
"type": "applied",
"name": "Phone screen",
"order": 1
},
"actor": {
"type": "user",
"id": "usr_01JB7X4M2Q8Z"
},
"reason": "Strong on customer empathy"
}
}interview.completed
The candidate finished an AI interview
- interview_idIdRequired
- application_idIdRequired
- kindstringRequired
Always ai today; human interviews are scheduled meetings with feedback, not sessions.
aihuman - completed_atstring (date-time)Required
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "interview.completed",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"interview_id": "int_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"kind": "ai",
"completed_at": "2026-10-26T09:30:00Z"
}
}scorecard.ready
The AI scorecard finished, with every citation checked
- application_idIdRequired
- scorecard_versionintegerRequired
- open_flagsintegerRequired
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "scorecard.ready",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"application_id": "app_01JB7X4M2Q8Z",
"scorecard_version": 1,
"open_flags": 0
}
}decision.made
A person recorded advance, hold or reject
- decision_idIdRequired
- application_idIdRequired
advanceholdreject
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "decision.made",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"decision_id": "dec_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"outcome": "advance",
"decided_by": {
"type": "user",
"id": "app_01JB7X4M2Q8Z"
}
}
}decision.undone
A person undid a decision inside its five minute undo window
- decision_idIdRequired
- application_idIdRequired
advanceholdreject
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "decision.undone",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"decision_id": "dec_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"outcome": "advance",
"undone_by": {
"type": "user",
"id": "app_01JB7X4M2Q8Z"
}
}
}offer.sent
An approved offer went to the candidate
- offer_idIdRequired
- application_idIdRequired
- candidate_idIdRequired
draftpending_approvalapprovedsentsigneddeclinedexpiredwithdrawn- document_sha256string | null
sha256 of the offer letter PDF as sent (offer.sent and offer.signed)
- signed_sha256string
sha256 of the signed copy (offer.signed)
- expires_onstring (date)
Last day the offer can be signed (offer.sent)
- reasonstring
The candidate's decline reason code (offer.declined)
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "offer.sent",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"offer_id": "off_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"candidate_id": "cand_01JB7X4M2Q8Z",
"status": "draft"
}
}offer.signed
The candidate signed the offer
- offer_idIdRequired
- application_idIdRequired
- candidate_idIdRequired
draftpending_approvalapprovedsentsigneddeclinedexpiredwithdrawn- document_sha256string | null
sha256 of the offer letter PDF as sent (offer.sent and offer.signed)
- signed_sha256string
sha256 of the signed copy (offer.signed)
- expires_onstring (date)
Last day the offer can be signed (offer.sent)
- reasonstring
The candidate's decline reason code (offer.declined)
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "offer.signed",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"offer_id": "off_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"candidate_id": "cand_01JB7X4M2Q8Z",
"status": "draft"
}
}offer.declined
The candidate declined the offer, with a reason code
- offer_idIdRequired
- application_idIdRequired
- candidate_idIdRequired
draftpending_approvalapprovedsentsigneddeclinedexpiredwithdrawn- document_sha256string | null
sha256 of the offer letter PDF as sent (offer.sent and offer.signed)
- signed_sha256string
sha256 of the signed copy (offer.signed)
- expires_onstring (date)
Last day the offer can be signed (offer.sent)
- reasonstring
The candidate's decline reason code (offer.declined)
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "offer.declined",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"offer_id": "off_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"candidate_id": "cand_01JB7X4M2Q8Z",
"status": "draft"
}
}document.sent
An agreement or contract went out for signature
- document_idIdRequired
- kindstringRequired
offeremployment_agreementndacontractor_agreementpolicyother - titlestringRequired
- statusstringRequired
sentcompleteddeclinedvoidedexpired - candidate_idId | nullRequired
- application_idId | nullRequired
- offer_idId | nullRequired
- original_sha256string | nullRequired
sha256 of the PDF as sent
- party_rolestring
The party that signed or declined (document.signed and document.declined)
candidatecompanywitness - completeboolean
True once every party has signed (document.signed)
- signed_sha256string
sha256 of the copy this signature produced, each copy with its own fingerprint (document.signed)
- final_sha256string | null
sha256 of the final copy with every signature and the certificate once complete, else null (document.signed)
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "document.sent",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"document_id": "app_01JB7X4M2Q8Z",
"kind": "offer",
"title": "Customer support lead",
"status": "sent",
"candidate_id": "cand_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"offer_id": "off_01JB7X4M2Q8Z",
"original_sha256": "string"
}
}document.signed
One party signed a document; complete is true once everyone has
- document_idIdRequired
- kindstringRequired
offeremployment_agreementndacontractor_agreementpolicyother - titlestringRequired
- statusstringRequired
sentcompleteddeclinedvoidedexpired - candidate_idId | nullRequired
- application_idId | nullRequired
- offer_idId | nullRequired
- original_sha256string | nullRequired
sha256 of the PDF as sent
- party_rolestring
The party that signed or declined (document.signed and document.declined)
candidatecompanywitness - completeboolean
True once every party has signed (document.signed)
- signed_sha256string
sha256 of the copy this signature produced, each copy with its own fingerprint (document.signed)
- final_sha256string | null
sha256 of the final copy with every signature and the certificate once complete, else null (document.signed)
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "document.signed",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"document_id": "app_01JB7X4M2Q8Z",
"kind": "offer",
"title": "Customer support lead",
"status": "sent",
"candidate_id": "cand_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"offer_id": "off_01JB7X4M2Q8Z",
"original_sha256": "string"
}
}document.declined
A party declined a document, which closes it
- document_idIdRequired
- kindstringRequired
offeremployment_agreementndacontractor_agreementpolicyother - titlestringRequired
- statusstringRequired
sentcompleteddeclinedvoidedexpired - candidate_idId | nullRequired
- application_idId | nullRequired
- offer_idId | nullRequired
- original_sha256string | nullRequired
sha256 of the PDF as sent
- party_rolestring
The party that signed or declined (document.signed and document.declined)
candidatecompanywitness - completeboolean
True once every party has signed (document.signed)
- signed_sha256string
sha256 of the copy this signature produced, each copy with its own fingerprint (document.signed)
- final_sha256string | null
sha256 of the final copy with every signature and the certificate once complete, else null (document.signed)
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "document.declined",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"document_id": "app_01JB7X4M2Q8Z",
"kind": "offer",
"title": "Customer support lead",
"status": "sent",
"candidate_id": "cand_01JB7X4M2Q8Z",
"application_id": "app_01JB7X4M2Q8Z",
"offer_id": "off_01JB7X4M2Q8Z",
"original_sha256": "string"
}
}candidate.data_request
The candidate asked to see or delete their data or for a human review. If you hold a copy, you have duties too
- request_idIdRequired
- candidate_idIdRequired
- kindstringRequired
accessdeletehuman_reviewhuman_interview - due_atstring (date-time)Required
{
"id": "evt_01JB7Y2K9T3V4W5X6Y7Z8A9B",
"type": "candidate.data_request",
"api_version": "v1",
"created_at": "2026-10-26T09:30:00Z",
"org_id": "org_01JB2M0Q",
"data": {
"request_id": "app_01JB7X4M2Q8Z",
"candidate_id": "cand_01JB7X4M2Q8Z",
"kind": "access",
"due_at": "2026-10-26T09:30:00Z"
}
}