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