Webhooks & Events
Signed events pushed to your server the moment a chat goes unanswered, a ticket changes or an article is published — plus a replayable pull feed.
Overview
Every event in your workspace is written to an append-only log, then delivered two ways: pushed to endpoints you register as a signed POST, and pulled from a cursor feed at GET /external/events?since=<seq>. They are the same events from the same rows — a webhook is an optimisation over polling, never the only copy. If a push never lands, the event is still in the feed, with the same id and the same seq.
conversation.queued, conversation.queued.escalated, conversation.picked_up and conversation.abandoned. Raise an alert on the first, clear it on the last two. Also new: per-widget endpoint scoping, a persisted delivery log with replay, a timestamped signature, and the pull feed.Quickstart
Register an endpoint from Settings → Webhooks in the dashboard, or over the API:
curl -X POST -H "X-Api-Key: nextcap_ck_YOUR_KEY" \-H "Content-Type: application/json" \-d '{"url": "https://yourapp.com/hooks/nextcap","name": "Ops alerting","events": ["conversation.queued", "conversation.picked_up"],"widget_ids": null}' \https://api.nextcap.ai/api/v1/external/webhooks
{"data": {"id": "wh_9f21c4a8…","name": "Ops alerting","url": "https://yourapp.com/hooks/nextcap","events": ["conversation.queued", "conversation.picked_up"],"widget_ids": null,"is_active": true,"failure_count": 0,"last_triggered_at": null,"secret": "whsec_3f8a…","created_at": "2026-09-08T10:14:02.000Z"}}
secret is returned once, at creation. If you lose it, call POST /external/webhooks/:id/rotate-secret — which mints a new one and invalidates the old immediately.Worked example: alert me when a chat goes unanswered
A visitor asks for a human, nobody picks it up, and you want a push notification on your own phone. The shape is raise on one event, clear on another — you never poll, and you never have to guess whether an alert is stale.
conversation.queuedraiseA visitor has waited past your first-nudge threshold and no agent has claimed the chat.
conversation.queued.escalatedescalateStill unclaimed at the second threshold — page the on-call, not just the team channel.
conversation.picked_upclearAn agent claimed it. Carries picked_up_by.
conversation.abandonedclearThe visitor left before anyone claimed it.
The thresholds are the ones you already set for staff alerts, in Live Chat → Settings. There is no second place to configure them and no duplicate nudging: the same sweep that sends your Telegram alert emits these events.
{"id": "evt_01k4y7q9m2c8v3n6p0r5t1w8xz","seq": 4127,"event": "conversation.queued","occurred_at": "2026-09-08T10:14:02.000Z","timestamp": "2026-09-08T10:14:02.000Z","org_id": "b41c9f2e-…","widget_id": "0c7a1d55-…","data": {"conversation": {"id": "5b2e77c1-…","subject": "Refund on order 88421","first_message": "Hi — I was charged twice for order 88421, can someone help?","status": "waiting_for_agent","waited_seconds": 92,"queue_depth": 3,"url": "https://app.nextcap.ai/en/dashboard/live-chat/5b2e77c1-…"},"widget": { "id": "0c7a1d55-…", "name": "Acme Support" },"channel": "widget","department": { "id": "9a3f…", "name": "Billing" },"visitor": {"id": "v_71b3…","name": "Alice Moreau","email": "alice@acme.com","verified": true,"locale": "fr","country": "FR"},"stage": 1}}
Everything you need for a useful notification is in that one payload — no follow-up API call. first_message is the “what is this about” line, queue_depth tells you whether this is one stuck chat or a pile-up, url deep-links your agent straight into it, and verified: true means the visitor passed identity verification. stage is 1 on the first nudge and 2 on conversation.queued.escalated; it is absent on picked_up and abandoned.
{"id": "evt_01k4y7t3f6b2q9d4h8j0m5n7pv","seq": 4131,"event": "conversation.picked_up","occurred_at": "2026-09-08T10:15:47.000Z","timestamp": "2026-09-08T10:15:47.000Z","org_id": "b41c9f2e-…","widget_id": "0c7a1d55-…","data": {"conversation": {"id": "5b2e77c1-…","subject": "Refund on order 88421","first_message": "Hi — I was charged twice for order 88421, can someone help?","status": "active","waited_seconds": 0,"queue_depth": 2,"url": "https://app.nextcap.ai/en/dashboard/live-chat/5b2e77c1-…"},"widget": { "id": "0c7a1d55-…", "name": "Acme Support" },"channel": "widget","department": { "id": "9a3f…", "name": "Billing" },"visitor": {"id": "v_71b3…","name": "Alice Moreau","email": "alice@acme.com","verified": true,"locale": "fr","country": "FR"},"picked_up_by": { "id": "u_2c8d…", "name": "Marc" }}}
const evt = JSON.parse(rawBody); // after verifying the signature// Same event delivered twice (our retry, your load balancer) must be a no-op.if (await alreadySeen(evt.id)) return;await markSeen(evt.id);const convId = evt.data.conversation?.id;switch (evt.event) {case "conversation.queued":await raiseAlert(convId, {title: `Chat waiting ${evt.data.conversation.waited_seconds}s`,body: evt.data.conversation.first_message ?? "(no message yet)",url: evt.data.conversation.url,urgent: false,});break;case "conversation.queued.escalated":await raiseAlert(convId, {title: `STILL waiting — ${evt.data.conversation.queue_depth} in queue`,body: evt.data.conversation.first_message ?? "(no message yet)",url: evt.data.conversation.url,urgent: true,});break;case "conversation.picked_up":case "conversation.abandoned":// Clearing an alert that was never raised must be safe: an outage on your// side can leave you holding only the second half of the pair.await clearAlert(convId);break;}
Two rules make this robust, and both are the same rule in different clothes. Key raise and clear on the conversation id and make them idempotent — raising twice shows one alert, clearing an alert you never raised does nothing. And a clear that arrives without its raise is normal: you were down when queued fired, or you subscribed halfway through. Do not treat it as an error.
let cursor = await loadCursor(); // the last seq you fully handledfor (;;) {const r = await fetch(`https://api.nextcap.ai/api/v1/external/events?since=${cursor}&limit=200`,{ headers: { "X-Api-Key": process.env.NEXTCAP_API_KEY } },).then((r) => r.json());for (const evt of r.data) await handle(evt); // the same switch as aboveif (r.next_seq === null) break; // caught upcursor = r.next_seq;await saveCursor(cursor); // AFTER the batch, not before}
Event catalog
Conversation — the live-chat lifecycle. The four marked events are the “nobody has answered this” set.
conversation.createdeventA new chat started.
conversation.queuedevent · alertingA handoff has gone unclaimed past your first threshold.
conversation.queued.escalatedevent · alertingStill unclaimed at the second threshold.
conversation.picked_upevent · alertingAn agent claimed a queued chat.
conversation.abandonedevent · alertingThe visitor left before anyone claimed it.
conversation.handed_offeventThe AI escalated to a human — the queue was entered.
conversation.transferredeventMoved to another agent, department or partner workspace.
conversation.completedeventThe conversation was resolved or closed.
Ticket: ticket.created, ticket.updated, ticket.status_changed, ticket.assigned, ticket.unassigned, ticket.sla_breach, ticket.department_changed, ticket.reopened, ticket.reply (an agent replied), ticket.reply_edited (an already-sent agent reply was edited), ticket.customer_reply (visitor reply, also fires for API-submitted replies).
Visitor: lead.created (a visitor submitted contact info), visitor.identified (a visitor’s identity was captured or verified).
lead.captured before September 2026. The platform has always emitted lead.created — the docs were wrong, not the code. If you subscribed to lead.captured you never received anything; resubscribe to lead.created.Call requests: call_request.created, call_request.updated, call_request.completed. The data matches the /external/call-requests response, including any custom visitor-form fields your organization configured.
Identity verification: verification.created, verification.processing, verification.completed, verification.approved, verification.rejected, verification.manual_review, verification.expired, fraud.flagged.
Help desk content — powers the incremental sync flow of the Helpdesk Content API: help_center.created/updated/deleted, help_collection.created/updated/deleted/reordered, help_category.created/updated/deleted/reordered, help_article.created/updated/deleted, help_article.published (draft → published, the typical “rebuild my help site” trigger) and help_article.reordered. A deleted article is soft-deleted and stays fetchable with deleted_at set.
Scoping an endpoint to specific widgets
widget_ids: nulldefaultEvery widget in the organization, plus org-wide events.
widget_ids: []optionOrg-wide events only.
widget_ids: ["0c7a…"]optionThose widgets, plus org-wide events.
Org-wide events — help desk content, identity verification — carry widget_id: null and are delivered to every endpoint regardless of scope. Scoping answers which widgets do I care about, not silence everything else. The usual reason to scope is one endpoint per brand, so your French storefront’s alerts do not wake up the team running the English one.
The envelope
{"id": "evt_01k4y7q9m2c8v3n6p0r5t1w8xz","seq": 4127,"event": "conversation.queued","occurred_at": "2026-09-08T10:14:02.000Z","timestamp": "2026-09-08T10:14:02.000Z","org_id": "b41c9f2e-…","widget_id": "0c7a1d55-…","data": { }}
idstringStable, unique, never reused. Your idempotency key.
seqnumberPer-organization counter, gap-free and monotonic.
eventstringThe event type, or 'test' for a test delivery.
occurred_atISO 8601When the thing happened.
org_iduuidYour organization.
widget_iduuid | nullThe widget it belongs to; null for org-wide events.
timestampISO 8601Deprecated alias of occurred_at, kept so v1 consumers keep working.
dataobjectThe event payload.
seq is how you detect a miss. It counts your organization’s events only, with no gaps. If you process 4127 and the next delivery is 4129, event 4128 did not reach you — fetch it with GET /external/events?since=4127&limit=10. That guarantee is the reason you can trust push delivery at all; nothing else in the envelope tells you what you didn’t receive.
Headers
X-Nextcap-Signatureheadert=<unix>,v1=<hex> — HMAC-SHA256 over `<t>.<raw body>`.
X-Nextcap-EventheaderThe event type.
X-Nextcap-Event-IdheaderSame as the envelope's id.
X-Nextcap-Delivery-IdheaderThis delivery's id (whd_…).
X-Nextcap-SequenceheaderSame as the envelope's seq.
X-Nextcap-Attemptheader1 on the first try, 2 on the first retry, and so on.
X-Webhook-SignaturedeprecatedThe old body-only HMAC hex digest. Still sent, but replayable — verify X-Nextcap-Signature instead.
X-Webhook-Signature is still sent on every delivery so existing integrations do not break, but it carries no timestamp and is therefore replayable: an attacker who captures one delivery can resend it verbatim, forever.Verifying the signature
X-Nextcap-Signature: t=1757326442,v1=9d4c8f1b2e7a…
v1 is HMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded. Parse t and v1, recompute the HMAC over the raw, unparsed bytes (parsing and re-serialising JSON reorders keys and breaks the hash), compare in constant time, and reject anything where |now − t| exceeds 300 seconds — without that window the timestamp buys you nothing.
const { createHmac, timingSafeEqual } = require("node:crypto");const TOLERANCE_SECONDS = 300;function verify(rawBody, header, secret) {if (!header) return false;const parts = Object.fromEntries(header.split(",").map((p) => p.split("=").map((s) => s.trim())),);const t = Number(parts.t);const received = parts.v1;if (!Number.isFinite(t) || typeof received !== "string") return false;// Replay window first — a flood of stale bodies should be cheap to reject.if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");// timingSafeEqual throws on a length mismatch, so guard it. Comparing the// hex strings with === would leak the correct prefix one byte at a time.const a = Buffer.from(expected, "utf8");const b = Buffer.from(received, "utf8");return a.length === b.length && timingSafeEqual(a, b);}// Express: mount express.raw so rawBody is the exact bytes we signed.app.post("/hooks/nextcap",express.raw({ type: "application/json" }),(req, res) => {if (!verify(req.body, req.header("X-Nextcap-Signature"), process.env.NEXTCAP_WEBHOOK_SECRET)) {return res.status(401).end();}res.status(200).end(); // ack first, work laterqueue.add(JSON.parse(req.body.toString("utf8")));},);
Retries
Any 2xx within 10 seconds is a success. Anything else — non-2xx, timeout, transport error — is retried 8 times at 10s, 1m, 5m, 15m, 1h, 2h, 4h, 6h. The last attempt lands roughly 13 hours after the event; after that the delivery is marked failed and stays in your delivery log, where you can replay it by hand. Retries are durable — a pending delivery is a database row, not a timer in memory, so it survives our deploys.
Auto-disable: 20 consecutive failures across deliveries flips the endpoint inactive and we stop attempting. failure_count resets to zero on any success, so this tracks a sustained outage rather than a lifetime tally. Re-enable it in the dashboard or with PATCH /external/webhooks/:id {"is_active": true}.
Delivery log and replay
Every attempt is recorded. This is the answer to “did you send it, and what did my server say?”
{"data": [{"id": "whd_7c1f…","event": "conversation.queued","event_id": "evt_01k4y7q9m2c8v3n6p0r5t1w8xz","seq": 4127,"status": "failed","attempts": 9,"response_status": 502,"last_error": "502 Bad Gateway","next_attempt_at": null,"delivered_at": null,"created_at": "2026-09-08T10:14:02.000Z"}],"next_cursor": "eyJjIjoiMjAyNi0wOS0wOFQxMDoxNDowMloifQ"}
status is pending, delivered or failed. Page with next_cursor until it comes back null. A replay re-POSTs the stored envelope, byte for byte — not a freshly rebuilt one — so it is identical to what you should have received, even if the underlying conversation has since been closed. It resets the delivery to pending with attempts back at zero: a replay means “my endpoint is fixed now, try again”, and inheriting the previous failures would burn the whole retry budget on the first attempt.
The pull feed
curl -H "X-Api-Key: nextcap_ck_YOUR_KEY" \"https://api.nextcap.ai/api/v1/external/events?since=4127&limit=200&event=conversation.queued"
sincenumberReturn events with seq greater than this. Omit to start at the beginning of the retention window.
limitnumberPage size.
eventstringFilter to one event type.
widget_iduuidFilter to one widget.
The response is { "data": [envelopes], "next_seq": number | null }, ascending by seq. Loop until next_seq is null. You do not need a registered endpoint for this — an API key alone is enough, which makes the feed the right choice if you cannot expose a public HTTPS endpoint at all.
Management endpoints
Security checklist
- Verify the signature before you trust the payload — including before you log it.
- Keep the secret in your secrets manager, never in source and never in a client bundle.
- Enforce the 300-second timestamp window. A signature without a freshness check is a replayable token.
- Compare with
timingSafeEqual/hash_equals. Never===. - Your endpoint must be
https. We refuse plainhttpand any address that resolves into a private network. - Ack fast, work asynchronously, and dedupe on
id.