Webhooks
What you get
Register an HTTPS endpoint and SurVoyce POSTs a JSON envelope to it as events happen. Every request is signed, failures are retried with exponential backoff, and every attempt is logged where you can inspect and replay it. Manage endpoints in Settings → Webhooks, over the REST API, or with the MCP tools — all three share one implementation, so they behave identically.
The envelope
Every delivery has the same top-level shape. `id` is the event id — stable across every endpoint subscribed to it and across replays, so it is what you should deduplicate on. The per-attempt delivery id travels in the SurVoyce-Delivery-Id header instead.
POST /your-endpoint
content-type: application/json
SurVoyce-Event: submission.completed
SurVoyce-Event-Id: 8f1c… # dedupe on this
SurVoyce-Delivery-Id: 3ab9… # this attempt
SurVoyce-Signature: t=1755264000,v1=9f3a…
{
"id": "8f1c…",
"type": "submission.completed",
"created_at": "2026-08-15T12:00:00.000Z",
"workspace_id": "…",
"data": {
"submission_id": "…",
"session_id": "…",
"form": { "id": "…", "title": "Customer NPS" },
"status": "accessible",
"channel": "phone",
"duration_seconds": 184,
"submitted_at": "2026-08-15T12:00:00.000Z"
}
} Verifying the signature
The signature is an HMAC-SHA256 over `${timestamp}.${rawBody}`, keyed with your signing secret — the same construction Stripe uses, so existing tooling and examples apply. Verify against the RAW body, before any JSON parsing, and reject timestamps outside a tolerance window: that is what makes a captured request impossible to replay later.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isInteger(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(parts.v1, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
} Subscribing to events
Subscriptions accept exact names ("submission.completed"), prefix wildcards ("form.*"), or "*" for everything. Available events cover forms, questions, sessions, answers, submissions, credits, outbound calls, campaigns, workspace settings and API keys. Fetch the full catalogue with descriptions from GET /v1/webhook-events (no auth needed) or the list_webhook_events MCP tool. A few events — answer.recorded, session.started, and the no-answer/voicemail call outcomes — fire many times per session and are flagged high-volume; do not point a chat channel at them.
curl https://api.survoyce.com/v1/webhook-events
Event reference
Every event an endpoint can subscribe to. This list is generated from the same catalogue the API and the dashboard use.
form.created- A draft form was created.
form.updated- A form's title, description or settings changed.
form.published- A form was published and can now be answered. Carries the web URL and phone form code.
form.unpublished- A form reverted to draft; its link and form code stopped working.
form.closed- A form was closed to new responses.
question.added- A question was added to a draft form.
question.updated- A question was edited on a draft form.
question.deleted- A question was removed from a draft form.
session.started- A respondent began answering, by web or phone. High volume.
session.abandoned- A respondent left, timed out, or hung up before finishing.
answer.recorded- A single answer was committed. Fires again when a respondent corrects themselves. High volume.
submission.completed- A submission was recorded. Includes keyed answers when the endpoint sets include_answers — never for a locked submission.
submission.unlocked- A previously locked submission became accessible after credits were added.
credit.purchased- Credits were purchased and added to the balance.
credit.adjusted- The credit balance was adjusted manually.
credit.depleted- A submission was stored locked because the balance had run out.
outbound_call.queued- An outbound call was queued.
outbound_call.connected- A queued call was answered by a person.
outbound_call.completed- An outbound call finished normally.
outbound_call.no_answer- An outbound call was not answered. High volume.
outbound_call.voicemail- An outbound call reached an answering machine. High volume.
outbound_call.failed- An outbound call could not be placed or dropped with an error.
campaign.created- A calling campaign was created.
campaign.started- A campaign started or resumed dialing.
campaign.paused- A campaign was paused.
campaign.canceled- A campaign was canceled; its undialed calls were dropped.
campaign.completed- Every call in a campaign reached a terminal state.
workspace.updated- Workspace settings changed, such as the organization spoken in the AI disclosure.
api_key.created- An API key was minted. Carries the key prefix only — never the secret.
api_key.revoked- An API key was revoked.
api_key.rotated- An API key was rotated; the old key keeps a short grace window.
webhook.endpoint_disabled- An endpoint was disabled automatically after sustained delivery failures. Subscribe a second endpoint to hear about the first.
Answers and locked submissions
By default a submission event carries ids, the form title, the channel and timings — not the answers themselves. Set include_answers to add the keyed answers and respondent contact details. Two rules always hold regardless of that flag: a LOCKED submission never includes answers, because it is data the workspace has not yet spent a credit to unlock; and the respondent origin (IP address for web, caller ID for phone) is never included in any webhook payload. Both are available through get_answers and the results export, which are authenticated and stay inside your account.
Retries and failures
A 2xx is success. 5xx, 429, 408, timeouts and connection errors are retried after roughly 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours (jittered) — six attempts in total. Other 4xx responses stop immediately, since no retry will fix them, and redirects count as failures because SurVoyce never follows them. A 410 Gone disables the endpoint straight away — that is the clean way to decommission an integration. After a sustained run of failures an endpoint is disabled automatically; you can subscribe a second endpoint to webhook.endpoint_disabled to hear about it, and re-enable from the dashboard once fixed.
Reshaping the payload
If your receiver expects a particular shape — a Slack or Discord incoming webhook, say — give the endpoint a JSON template. Strings in it may contain {{path}} references into the envelope. A string that is exactly one placeholder keeps the referenced value's type; mixed with other text it interpolates. This is substitution only: there are no conditionals, loops or expressions.
{ "text": "New response to {{data.form.title}} ({{data.status}})" }
// becomes
{ "text": "New response to Customer NPS (accessible)" } Managing endpoints over REST
All routes take the same bearer API key as the rest of the API. Creating an endpoint returns the signing secret exactly once; it is never retrievable afterwards, only replaceable via rotate-secret.
POST https://api.survoyce.com/v1/webhooks # create — returns the secret ONCE
GET https://api.survoyce.com/v1/webhooks # list
GET https://api.survoyce.com/v1/webhooks/{id}
PATCH https://api.survoyce.com/v1/webhooks/{id}
DELETE https://api.survoyce.com/v1/webhooks/{id}
POST https://api.survoyce.com/v1/webhooks/{id}/test # queue a test delivery
POST https://api.survoyce.com/v1/webhooks/{id}/rotate-secret
GET https://api.survoyce.com/v1/webhooks/{id}/deliveries # the delivery log
POST https://api.survoyce.com/v1/webhook-deliveries/{id}/replay
GET https://api.survoyce.com/v1/webhook-events # catalogue (public)
curl -X POST https://api.survoyce.com/v1/webhooks \
-H 'authorization: Bearer YOUR_API_KEY' \
-H 'content-type: application/json' \
-d '{"url": "https://example.com/hook",
"events": ["submission.completed", "form.*"],
"includeAnswers": true}' Requirements and limits
Endpoints must be HTTPS and resolve to a public address — private, loopback, link-local and cloud-metadata addresses are refused, and are re-checked before every delivery attempt, not only at registration. Each request has a 10-second budget. Response bodies are stored truncated in the log for debugging. Up to 20 endpoints per workspace. Delivery logs are pruned after 30 days.
A newly created or re-enabled endpoint begins receiving events within about a minute. If a test delivery does not arrive, check the delivery log on the endpoint page — it records the HTTP status, the response body, and any connection error.
Machine-readable version: /docs/webhooks.md · full corpus at /llms-full.txt