Receiving webhooks
A webhook is an address on your server that Zentiq calls when something happens: a lead arrives, a result is released, a candidate is screened, a session ends. Each delivery is signed, retried until your server takes it, and recorded.
A webhook is a signal. It says what happened, with ids and numbers; when you need more (a lead's details, a candidate list, a transcript), your server asks the API with a key allowed to, where the privacy rules apply.
Register one#
curl -X POST https://app.zentiqai.com/v1/webhooks \
-H "Authorization: Bearer $ZENTIQ_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "url": "https://crm.example.com/zentiq/webhooks", "events": ["lead.captured", "session.ended"], "description": "CRM" }'url: a publichttpsaddress. Private and local addresses are refused, and checked again before every delivery.events: the types it receives (the event catalogue), or["*"]for all of them excepttranscript.final, which must be named.includeConversation(defaultfalse): also send the words that may leave Zentiq (see below).
The answer holds the webhook's signing secret, once: store it with your server's secrets. The key needs webhooks:manage. GET, PATCH and DELETE /v1/webhooks/{id} read, change and remove it.
What a delivery looks like#
A POST to your address, with a JSON body:
{
"id": "evt_cmfq2b7x10000abcd1234",
"type": "lead.captured",
"apiVersion": "2026-10-01",
"organizationId": "org_cmfq2b7x10000abcd1234",
"createdAt": "2026-10-01T09:00:00.000Z",
"data": { "inboxItemId": "inb_…", "contact": { "name": "Jo Smith", "phone": "+1 555 0100" }, "consent": { "purpose": "callback", "marketing": false } }
}id(evt_…) is the event's own id: the same on every retry. Use it to drop repeats.createdAtis when it happened, not when it was sent.datais exactly what the catalogue lists for the type; a field the rules hold back is null.aiGenerated: trueis there whendatacarries words an avatar wrote (a summary, a report, an avatar's turn intranscript.final): keep it with them. See Telling people it's an AI.- A webhook made by a key on the
2026-07-08contract gets{ id, type, createdAt, data }, with the ids indataas that contract had them.PATCH /v1/webhooks/{id}with"apiVersion": "2026-10-01"moves it.
With it come these headers:
| Header | What it holds |
|---|---|
X-Zentiq-Signature | sha256= and the hex HMAC-SHA256 of "<X-Zentiq-Timestamp>.<raw body>", keyed with the webhook's secret. |
X-Zentiq-Timestamp | When this attempt was signed, in milliseconds since 1970. |
X-Zentiq-Event | The event type. |
X-Zentiq-Event-Id | The event's id (the body's id). |
X-Zentiq-Delivery-Id | This delivery's id (whd_…), as the delivery log shows it. |
Verify every delivery#
Check the signature on the raw body, exactly as it arrived (not a parsed and re-serialised copy), and refuse a timestamp more than five minutes old, so a copied delivery can't be replayed later. The SDKs do both:
import { verifyWebhook, WebhookSignatureError, Zentiq, type ZentiqWebhookEvent } from "@zentiq/node";
const zentiq = new Zentiq({ apiKey: process.env.ZENTIQ_API_KEY });
const secret = process.env.ZENTIQ_WEBHOOK_SECRET ?? "";
const seen = new Set<string>(); // use your database in production
http
.createServer(async (req, res) => {
let raw = "";
for await (const chunk of req) raw += chunk; // the raw body, exactly as it arrived
let event: ZentiqWebhookEvent;
try {
event = verifyWebhook(raw, req.headers, secret);
} catch (e) {
res.writeHead(e instanceof WebhookSignatureError ? 400 : 500).end();
return;
}
res.writeHead(204).end(); // acknowledge first; do the work after
if (seen.has(event.id)) return; // the same event can arrive more than once
seen.add(event.id);
await handle(event);
})
.listen(4000);Without an SDK, in any language: compute HMAC-SHA256(secret, timestamp + "." + rawBody) as hex, prefix sha256=, compare it with X-Zentiq-Signature in constant time, and check the timestamp.
Answer quickly, work later#
Answer with any 2xx within 10 seconds, then do the work. Anything else is a failed attempt: another status, no answer in 10 seconds, or a redirect (a 3xx is never followed: move the webhook to its new address instead).
- Retries: after a failed first attempt, Zentiq tries again 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours later: about two days in all. Each attempt is signed afresh.
- Pausing: a webhook whose every delivery has failed for three days is paused (
active: false,pausedAtsays when) and receives nothing until you resume it withPATCH /v1/webhooks/{id}and{ "active": true }. - At least once, in any order: an event can arrive twice (drop repeats by
id), and events can arrive out of order (usecreatedAt). - Rebuilt each time: every attempt's body is made again from the recorded event with the rules as they are then, so a retry never carries a detail that has since been erased.
The delivery log and the event log#
GET /v1/webhooks/deliverieslists every delivery: itsstate(pending,retrying,delivered,failed), the status your server answered, how long it took, and when the next attempt is. Filter bywebhookId,eventId,stateorok=false.GET /v1/events(scopeevents:read) lists every event your webhooks were offered, with the same ids and the samedataa webhook with your key's rights receives. Each kind of event also needs the scope that reads its records (results needresults:read, people's changespeople:read, leads and bookingsinbox:read, sessionssessions:read, and so on: the reference lists them), so a key sees only the events of what it may read. After an outage, catch up withafter(the last event id you handled) andorder=asc.GET /v1/event-typesis the catalogue itself, with each type's fields and its data's JSON Schema.
includeConversation#
Some events can carry words from the conversation: the summary on session.ended and report.produced, a report's feedback lines, a visitor's own words on leads, bookings and hand-offs, a rating's comment, knowledge.gap's questions, case_link.opened's case summary, and transcript.final (each finished turn of a session your app started). They go only to a webhook made with includeConversation: true, which only a key holding transcripts:read can turn on (every change is recorded in the organisation's activity log), and never from a young learner's, a candidate's or a safety-flagged session.
The events#
session.startedsession.endedtranscript.finalvoice.clone.completedusage.thresholdknowledge.document.processedknowledge.document.failedscore.recordedmastery.updatedmemory.writtenassignment.createdassignment.startedassignment.completedpractice.startedlearner.signed_invisitor.session.endedreport.producedassessment.completedreview.requestedreview.completedresult.contestedcertificate.revokedcandidate.screenedcandidate.decidedcandidate.erasedcandidate.invitedcandidate.invite_cancelledcandidate.reviewedcandidate.interview_offeredcandidate.interview_bookedcandidate.interview_cancelledattestation.recordedknowledge.gapcertificate.issuedcompletion.recordedcompletion.withdrawnattempt.grantedcertificate.expiringcertificate.expiredhandoff.requestedhandoff.updatedlead.capturedbooking.requestedlead.updatedbooking.updatedhandoff.joinedhandoff.leftvisitor.detail_removedconversation.ratedconsent.recordedsetting.changedmember.addedmember.removedmember.updatedmember.disabledmember.enabledunit.createdunit.updatedunit.renamedunit.disabledunit.enabledunit.deletedworkspace.createdworkspace.publishedportal.createdportal.updatedsession.transcript_readysubject.draft_savedwidget.updatedjob.completedcandidate.invite_openedcandidate.invite_expiredwebhook.testescalation.completedcase_link.openedconnection.failed
Each one, with what it says and every field it can carry, is in the event catalogue.