Next CapNext Cap

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.

New (September 2026): four live-chat events built for “nobody has answered this” alerting — 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:

Register an endpointbash
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
Response — 201 Createdjson
{
"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"
}
}
The 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.queuedraise

A visitor has waited past your first-nudge threshold and no agent has claimed the chat.

conversation.queued.escalatedescalate

Still unclaimed at the second threshold — page the on-call, not just the team channel.

conversation.picked_upclear

An agent claimed it. Carries picked_up_by.

conversation.abandonedclear

The 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.

conversation.queued payloadjson
{
"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.

conversation.picked_up payload (same shape, plus who took it)json
{
"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" }
}
}
Raise and clearjavascript
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.

Catch up after an outagejavascript
let cursor = await loadCursor(); // the last seq you fully handled
for (;;) {
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 above
if (r.next_seq === null) break; // caught up
cursor = 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.createdevent

A new chat started.

conversation.queuedevent · alerting

A handoff has gone unclaimed past your first threshold.

conversation.queued.escalatedevent · alerting

Still unclaimed at the second threshold.

conversation.picked_upevent · alerting

An agent claimed a queued chat.

conversation.abandonedevent · alerting

The visitor left before anyone claimed it.

conversation.handed_offevent

The AI escalated to a human — the queue was entered.

conversation.transferredevent

Moved to another agent, department or partner workspace.

conversation.completedevent

The 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).

Renamed: this event was documented as 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: nulldefault

Every widget in the organization, plus org-wide events.

widget_ids: []option

Org-wide events only.

widget_ids: ["0c7a…"]option

Those 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

Every delivery, push and pull alikejson
{
"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": { }
}
idstring

Stable, unique, never reused. Your idempotency key.

seqnumber

Per-organization counter, gap-free and monotonic.

eventstring

The event type, or 'test' for a test delivery.

occurred_atISO 8601

When the thing happened.

org_iduuid

Your organization.

widget_iduuid | null

The widget it belongs to; null for org-wide events.

timestampISO 8601

Deprecated alias of occurred_at, kept so v1 consumers keep working.

dataobject

The 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-Signatureheader

t=<unix>,v1=<hex> — HMAC-SHA256 over `<t>.<raw body>`.

X-Nextcap-Eventheader

The event type.

X-Nextcap-Event-Idheader

Same as the envelope's id.

X-Nextcap-Delivery-Idheader

This delivery's id (whd_…).

X-Nextcap-Sequenceheader

Same as the envelope's seq.

X-Nextcap-Attemptheader

1 on the first try, 2 on the first retry, and so on.

X-Webhook-Signaturedeprecated

The 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.

Verify a delivery
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 later
queue.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}.

Respond fast. Acknowledge with a 2xx and do the real work in your own queue. A handler that takes 12 seconds is a failed delivery even when it succeeds.

Delivery log and replay

Every attempt is recorded. This is the answer to “did you send it, and what did my server say?”

GET /external/webhooks/:id/deliveries?status=failedjson
{
"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

Walk the log from a cursorbash
curl -H "X-Api-Key: nextcap_ck_YOUR_KEY" \
"https://api.nextcap.ai/api/v1/external/events?since=4127&limit=200&event=conversation.queued"
sincenumber

Return events with seq greater than this. Omit to start at the beginning of the retention window.

limitnumber

Page size.

eventstring

Filter to one event type.

widget_iduuid

Filter 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.

Retention is 30 days. An event older than that is pruned and gone from the feed for good. If your consumer can be down longer than a month, snapshot what you have processed rather than planning to backfill from us.

Management endpoints

GET/external/webhooksAPI Key

List your registered endpoints.

POST/external/webhooksAPI Key

Register an endpoint. Returns the signing secret, once.

urlstringrequired

HTTPS URL that will receive deliveries.

eventsstring[]required

Event types to subscribe to.

namestring

Operator-facing label, shown in the dashboard and the delivery log.

descriptionstring

What this endpoint is for.

widget_idsuuid[] | null

null (default) = every widget. [] = org-wide events only.

GET/external/webhooks/:idAPI Key

Fetch one endpoint, with its delivery health.

PATCH/external/webhooks/:idAPI Key

Update url, events, name, description, widget_ids or is_active.

DELETE/external/webhooks/:idAPI Key

Delete an endpoint and its delivery history.

POST/external/webhooks/:id/testAPI Key

Send a test delivery — event 'test', signed exactly like a real one.

POST/external/webhooks/:id/rotate-secretAPI Key

Mint a new signing secret. The old one stops verifying immediately.

GET/external/webhooks/:id/deliveriesAPI Key

The delivery log. Filter with ?status= and page with ?cursor=.

POST/external/webhooks/:id/deliveries/:deliveryId/replayAPI Key

Re-send a stored delivery.

GET/external/eventsAPI Key

The pull feed. Cursor with ?since=<seq>.

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 plain http and any address that resolves into a private network.
  • Ack fast, work asynchronously, and dedupe on id.