# Webhooks

Get signed, retried HTTP callbacks when things happen — submissions, forms, credits, calls.

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

```http
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.

```javascript
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.

```bash
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.

```javascript
{ "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.

```bash
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.
