Skip to the content
Menu: Receiving webhooks

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#

Shell
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 public https address. 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 except transcript.final, which must be named.
  • includeConversation (default false): 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:

JSON
{
  "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.
  • createdAt is when it happened, not when it was sent.
  • data is exactly what the catalogue lists for the type; a field the rules hold back is null.
  • aiGenerated: true is there when data carries words an avatar wrote (a summary, a report, an avatar's turn in transcript.final): keep it with them. See Telling people it's an AI.
  • A webhook made by a key on the 2026-07-08 contract gets { id, type, createdAt, data }, with the ids in data as that contract had them. PATCH /v1/webhooks/{id} with "apiVersion": "2026-10-01" moves it.

With it come these headers:

HeaderWhat it holds
X-Zentiq-Signaturesha256= and the hex HMAC-SHA256 of "<X-Zentiq-Timestamp>.<raw body>", keyed with the webhook's secret.
X-Zentiq-TimestampWhen this attempt was signed, in milliseconds since 1970.
X-Zentiq-EventThe event type.
X-Zentiq-Event-IdThe event's id (the body's id).
X-Zentiq-Delivery-IdThis 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:

Node
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, pausedAt says when) and receives nothing until you resume it with PATCH /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 (use createdAt).
  • 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/deliveries lists every delivery: its state (pending, retrying, delivered, failed), the status your server answered, how long it took, and when the next attempt is. Filter by webhookId, eventId, state or ok=false.
  • GET /v1/events (scope events:read) lists every event your webhooks were offered, with the same ids and the same data a webhook with your key's rights receives. Each kind of event also needs the scope that reads its records (results need results:read, people's changes people:read, leads and bookings inbox:read, sessions sessions: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 with after (the last event id you handled) and order=asc.
  • GET /v1/event-types is 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.