API
Webhooks
Be told when something happens — a tool subscribes by making one.
A webhook is an address of yours that is told when an event happens in the organization: a POST, with what is said of the event as its body, with the organization’s webhook token and signed with it (see Organization and Check that it is ours). One event per webhook. A tool subscribes by making one and unsubscribes by deleting it; the same webhooks are on the dashboard, under Webhooks, with how each sending went.
Events
Section titled “Events”trigger |
When |
|---|---|
webinarRegistration.created |
Somebody registered for a webinar, whichever way. Once per person, with a launch’s sessions in a list. |
surveyResponse.created |
A registration sent a response to a survey. |
An evergreen has no sessions: the time picked is on the registration’s
webinarEvergreenRegistration. A launch’s sessions come in a list, as they stood when
it was sent. A date added later says nothing to anyone already registered, unless something
of yours does it.
What a webhook is for is said in what its event is of. For the two webinar events,
webinars: allWebinars is every webinar, the ones made later too, else the ones
in webinarIds. For surveyResponse.created, surveys the same way: allSurveys
or surveyIds. The ones picked have to be yours and not deleted
(404 WEBINAR_NOT_FOUND, 404 SURVEY_NOT_FOUND).
Active and inactive
Section titled “Active and inactive”active says whether events reach it. An active webhook needs all of these, and
is refused with what it lacks:
| It lacks | Refused with |
|---|---|
A name |
409 ORGANIZATION_WEBHOOK_NAME_MISSING |
A trigger |
409 ORGANIZATION_WEBHOOK_TRIGGER_MISSING |
| Webinars, for either webinar event | 409 ORGANIZATION_WEBHOOK_WEBINARS_MISSING |
Surveys, for surveyResponse.created |
409 ORGANIZATION_WEBHOOK_SURVEYS_MISSING |
An inactive webhook needs only its name and its url: trigger may be null
and it may be for nothing.
While inactive, an event makes no delivery, then or later. A delivery made
while it was active still goes out, its retries too, and a failed one can still
be tried again from the dashboard. A test can be sent from the dashboard while
inactive, if it has an event.
Deleting a webinar or a survey takes it out of webinarIds or surveyIds on every
webhook that has it, and restoring it does not put it back. A webhook it was all
that was left for becomes inactive, with nothing picked, and the organization’s
owners and managers are told; one with others left stays active with those, and
one for every webinar or survey goes on as it was.
Make one
Section titled “Make one”/public/v1/webhooks{ "name": "CRM sign-ups", "active": true, "trigger": "webinarRegistration.created", "allWebinars": true, "webinarIds": [], "allSurveys": false, "surveyIds": [], "url": "https://hooks.example.com/sparkwebinar"}name and url are always sent. The rest may be left out: active starts
true, trigger null, and what it is for empty, so what is left out has to fit
its state. url is https (400 otherwise); an address
that resolves to a private network, or on a port other than 80, 443, 8080 or 8443,
is refused as it is sent, and the delivery says so.
List, read, change, delete
Section titled “List, read, change, delete”/public/v1/webhooks/public/v1/webhooks/:organizationWebhookId/public/v1/webhooks/:organizationWebhookId/public/v1/webhooks/:organizationWebhookIdA change sends whichever of the fields changed, "trigger": null to clear the
event, and what the webhook comes to has to fit its state.
A webhook deleted is told nothing more; sendings already on their way are not
recalled.
What is sent
Section titled “What is sent”Every sending is a POST to the webhook’s url, content-type: application/json, with the token and the signature in its headers — check
either (below), and answer any 2xx. The body opens with the delivery’s own
id — the same on every attempt at it, so a request received twice is
known to be one — createdAt, the instant the event happened, and
test, true on one sent from the dashboard to see it arrive; then the
event:
{ "id": 318, "createdAt": "2026-10-26T15:26:15.600Z", "test": false, "event": "webinarRegistration.created", "webinarRegistration": { "id": 109, "webinarId": 2, "email": "ana@example.com", "webinarLaunchSessionRegistrations": [], "...": "..." }, "webinarLaunchSessions": [{ "id": 412, "startsAt": "2026-10-26T22:00:00.000Z", "status": "open" }], "webinar": { "id": 2, "name": "Growth webinar", "title": "How we grow", "scheduleMethod": "launch", "webinarLaunch": { "method": "recorded" } }}webinarRegistration is the registration, with its
access token and where it sits, webinarEvergreenRegistration or
webinarLaunchSessionRegistrations, without what it did there.
On a launch’s webinarRegistration.created, top-level webinarLaunchSessions lists its
sessions as they stood when it was sent, soonest first, each with id, startsAt and
status (open, canceled or closed), leaving out the ones called off. An open session
is on from startsAt. It is absent on any other event.
Inside webinarRegistration, webinarLaunchSessionRegistrations lists only the sessions
the person has entered, so it is empty as they register. An evergreen’s time is
webinarRegistration.webinarEvergreenRegistration.startsAt.
webinar is its id, name, title and scheduleMethod (launch or evergreen), and
on a launch webinarLaunch.method (live or recorded). On
surveyResponse.created there are also survey, with id and name, and
answers, each with fieldId, value and the question’s title.
Not answered with a 2xx, a delivery is tried again a minute later, then after five minutes, half an hour, two hours and twelve — six attempts in all, over about fourteen hours — and then given up on.
Check that it is ours
Section titled “Check that it is ours”Each request carries:
| Header | What it is |
|---|---|
x-webhook-token |
The organization’s webhookToken, as it is. |
x-webhook-timestamp |
When it was sent, in Unix seconds. Each attempt is signed anew, at its own instant. |
x-webhook-signature |
sha256= and the hex HMAC-SHA256 of <timestamp>.<body>, keyed with the organization’s webhookToken. |
The simplest check is x-webhook-token equal to your webhookToken, which
any tool can do with no code. The signature is the stronger one: it also says
the body was not altered and that the request was sent just now.
Check the signature
Section titled “Check the signature”The body is the raw bytes received, before any JSON parsing. Refuse a
request whose signature does not match, and one whose timestamp is more
than five minutes away from your clock: a request recorded and sent again
later is then worth nothing. A delivery’s id is the same on every
attempt, so one received twice is told apart.
In Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request's body as received, a string or a Buffer.function isOurs(rawBody, headers, webhookToken) { const timestamp = Number(headers['x-webhook-timestamp']); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false; const expected = 'sha256=' + createHmac('sha256', webhookToken).update(`${timestamp}.${rawBody}`).digest('hex'); const received = String(headers['x-webhook-signature'] ?? ''); return received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));}Once the token is made anew, on the dashboard, every request from then on is signed with the new one, retries of older deliveries included.
Deliveries
Section titled “Deliveries”/public/v1/webhooks/:organizationWebhookId/deliveriesOne row per event sent to this webhook, newest first: the body as it
was sent — but for the registration’s accessToken, shown as •••••• —, attempts, the last attempt’s instant and status — or
lastError: ADDRESS_NOT_ALLOWED, REQUEST_TIMED_OUT, REQUEST_FAILED
—, nextAttemptAt while one is due, and its status: pending while
attempts go on, then delivered or failed, with deliveredAt or
failedAt.
One that ended is deleted 15 days after that instant, so the list and its
total go back no further, and the dashboard can no longer try a failed one
again. One pending is never deleted this way.