Webhooks and events
Get told when something happens: signed webhooks, an event log to catch up from, and a stream to wait on.
Three ways to hear about the same events, all carrying the same envelope:
- Webhooks push. Subscribe an endpoint to event types and ShowPilot posts each event to it, signed, with retries.
- The log lets you catch up.
GET /v1/events?since=<id>returns events in order from where you stopped. - The stream lets you wait.
GET /v1/events/streamis server-sent events for agents that cannot host a receiver.
Endpoints can also be added by hand in Settings → Webhooks, which shows each endpoint's recent deliveries and lets you replay one. The API and the screen manage the same subscriptions.
The envelope
{
"id": "1042",
"type": "meeting.booked",
"occurredAt": "2026-09-25T14:02:11.000Z",
"data": { "meetingId": "…", "visitorId": "…", "startAt": "2026-09-29T15:00:00.000Z", "attendee": { "email": "sam@example.com", "name": "Sam" } },
"links": { "meeting": "/v1/meetings/…", "visitor": "/v1/visitors/…" }
}id is monotonic within your workspace, so it doubles as the log cursor and as a deduplication key: delivery is at least once. links are API paths you can fetch with the same key.
Event types
| Type | Fires when | data carries |
|---|---|---|
conversation.started | A chat or voice session opens | conversationId, agentId, visitorId, channel, pageUrl, referrer |
conversation.engaged | The visitor sends a second message | conversationId, agentId, visitorId, channel, pageUrl |
conversation.ended | Quiet for 30 minutes (the worker closes it) | conversationId, agentId, visitorId, channel, startedAt, endedAt, durationSecs, reason |
visitor.identified | An anonymous visitor gives an email | visitorId, email, name, source, account, crm |
lead.identified | A visitor becomes a lead through a form or the agent | leadId, visitorId, conversationId, agentId, email, name, source, verdict |
intent.hot | The agent scores a visitor as hot | visitorId, conversationId, agentId, email, intentScore, account, routedTo |
meeting.booked | A booking is confirmed | meetingId, visitorId, conversationId, agentId, leadId, provider, startAt, attendee |
form.submitted | Any form completes | formId, formName, formKind, outcome, visitorId, email, answers |
segment.entered | A visitor now matches a saved segment | segmentId, segmentName, visitorId, email, displayName, account, crm |
segment.left | A visitor no longer matches | same as segment.entered |
offer.shown | The agent puts an offer in front of a visitor | offerId, presentation, agentId, visitorId, pageUrl |
offer.accepted | They take it | same as offer.shown |
offer.declined | They say no | same as offer.shown |
tour.played | A visitor starts a tour | tourId, tourPublicId, sittingId, visitorId, email, source, stepCount |
tour.step_viewed | One per step | tourId, tourPublicId, sittingId, visitorId, email, stepIndex, screenId |
tour.cta_clicked | A call to action inside a tour | tourId, tourPublicId, sittingId, visitorId, email, stepIndex, ctaId, url |
tour.completed | A visitor reaches the end | tourId, tourPublicId, sittingId, visitorId, email, durationSecs, stepsViewed |
experience.viewed | A shared experience page opens (no visitor yet; the tours inside carry one) | experienceId, experiencePublicId, name, shareHash, slug, referrer |
checklist.task_completed | One task in a hub checklist | tourId, sittingId, visitorId, checklistId, taskId |
checklist.converted | The checklist call to action | tourId, sittingId, visitorId, checklistId, ctaId, url |
follow_up.created | The agent promises to follow up on a question | followUpId, visitorId, conversationId, agentId, question |
follow_up.answered | A rep answers it | followUpId, visitorId, conversationId, question, answer, emailed |
The operator's own tour plays from the dashboard never fire events. Subscribe with ["*"] to receive every type, including ones added later.
Verifying a delivery
Each POST carries X-ShowPilot-Timestamp (unix seconds), X-ShowPilot-Signature (sha256= followed by an HMAC-SHA256 of <timestamp>.<raw body> using your subscription's secret), X-ShowPilot-Event (the type) and X-ShowPilot-Delivery (the delivery id). Compute the HMAC over the raw body exactly as received, compare in constant time, and reject timestamps older than five minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(req, rawBody, secret) {
const ts = req.headers['x-showpilot-timestamp'];
const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const given = req.headers['x-showpilot-signature'] ?? '';
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
return fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Respond with any 2xx within ten seconds. Anything else is retried with backoff over five attempts, and the delivery log shows each outcome. Twenty failures in a row pause the subscription; set its status back to active to resume, or replay a delivery to test the fix.
Setting one up
POST /v1/webhookswith your url and the event types. The response is the only time thesecretis shown.- Trigger something, or replay a delivery, and watch
GET /v1/webhooks/{id}/deliveries. - For a receiver you cannot host yet, read
GET /v1/eventson a schedule instead; thesincecursor makes it exactly as complete.
Subscriptions and the log need a key with the webhooks:manage scope. Private and local addresses are refused as endpoints.
Creating, changing or removing a subscription and replaying a delivery each take an Idempotency-Key header, so a retried request never makes a second subscription or a second replay. See Errors.