Appearance
Webhooks
Receive real-time HTTP notifications when responses are started, completed, or abandoned, when a survey is closed, or when an AI summary is generated.
Base URL: https://app.krafter.dev/api/v1
All webhook endpoints are nested under a survey: /surveys/:survey_id/webhooks. Signing, retry policy, headers, and auto-disable rules are shared across the platform — see Webhook delivery for the canonical contract.
Events
| Event | When it fires | data payload |
|---|---|---|
response.started | A respondent calls POST /api/v1/surveys/:slug/responses (or opens the embed widget for the first time) | { "response_id": "..." } |
response.completed | Response is finalised via POST /complete — also bumps the survey's response_count | { "response_id": "..." } |
response.abandoned | A mark_abandoned cron run flips a stale in_progress response to abandoned | { "response_id": "..." } |
survey.closed | Survey transitions to closed (via API or dashboard) | { "survey_id": "..." } |
summary.generated | An AI summary finishes generating — see Analytics | { "summary_id": "..." } |
A webhook with events: [] receives nothing. Pass the events you want explicitly. To receive every event, list all five.
Use the show endpoint to fetch full data
Webhook payloads carry ids only, not full objects. Your handler should call GET /api/v1/surveys/:survey_id/responses/:response_id (or /summary) to fetch the body it needs. This keeps webhook bodies small and immune to unrelated schema changes.
The Webhook object
| Field | Type | Description |
|---|---|---|
id | string | UUID |
survey_id | string | Owning survey |
url | string | Destination URL |
events | string[] | Subscribed event names |
active | boolean | Whether the webhook is currently delivering. Auto-disabled after 10 consecutive failures |
secret | string | Signing secret. Only returned on creation (or on PATCH if you supply a new one). Never returned on list/show |
failure_count | integer | Consecutive failures since the last successful delivery. Reset to 0 on any 2xx |
last_triggered_at | string | null | ISO 8601 timestamp of the last delivery attempt (success or failure) |
created_at | string | ISO 8601 timestamp |
List Webhooks
GET /surveys/:survey_id/webhooksRequired scope: surveys:read
bash
curl https://app.krafter.dev/api/v1/surveys/SURVEY_ID/webhooks \
-H "Authorization: Bearer kr_live_abc123def456"json
{
"data": [
{
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"survey_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"url": "https://api.yourapp.com/webhooks/surveys",
"events": ["response.completed", "response.abandoned"],
"active": true,
"failure_count": 0,
"last_triggered_at": "2026-05-09T10:30:00Z",
"created_at": "2026-05-01T08:00:00Z"
}
]
}The secret is never included in list / show responses.
Create Webhook
POST /surveys/:survey_id/webhooksRequired scope: surveys:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Destination URL. Must be http:// or https:// (HTTP allowed today, HTTPS strongly recommended) |
events | string[] | No | Subset of the five event names. Defaults to [] (no deliveries) |
active | boolean | No | Defaults to true |
A 64-character hex signing secret is generated automatically and returned in the response. Store it — it is the only time it is shown.
bash
curl -X POST https://app.krafter.dev/api/v1/surveys/SURVEY_ID/webhooks \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/webhooks/surveys",
"events": ["response.completed", "response.abandoned"]
}'json
// 201 Created
{
"data": {
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"survey_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"url": "https://api.yourapp.com/webhooks/surveys",
"events": ["response.completed", "response.abandoned"],
"active": true,
"secret": "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"failure_count": 0,
"last_triggered_at": null,
"created_at": "2026-05-10T10:00:00Z"
}
}WARNING
The secret is only returned at creation time. Store it securely — you will need it to verify webhook signatures, and there is no rotation endpoint. If lost, delete and recreate the webhook.
Update Webhook
PATCH /surveys/:survey_id/webhooks/:webhook_idRequired scope: surveys:write
Accepts url, events, active. Send only fields you want to change. Re-enabling a webhook (active: true) does not reset failure_count — only a successful delivery does.
bash
curl -X PATCH https://app.krafter.dev/api/v1/surveys/SURVEY_ID/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"events": ["response.completed", "summary.generated"]}'Delete Webhook
DELETE /surveys/:survey_id/webhooks/:webhook_idRequired scope: surveys:write
204 No ContentTest Webhook
POST /surveys/:survey_id/webhooks/:webhook_id/testRequired scope: surveys:write
Enqueues a test delivery using the same delivery worker as production events. Useful to confirm your endpoint signature verification works.
bash
curl -X POST https://app.krafter.dev/api/v1/surveys/SURVEY_ID/webhooks/WEBHOOK_ID/test \
-H "Authorization: Bearer kr_live_abc123def456"json
{ "ok": true, "message": "Test webhook dispatched" }The test request body is:
json
{
"event": "test",
"survey_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"data": {
"test": true,
"triggered_at": "2026-05-10T10:00:00Z"
}
}The x-krafter-event header is test and the request is signed exactly like a production delivery.
Payload format
Every delivery POST has the same envelope:
json
{
"event": "response.completed",
"survey_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"data": {
"response_id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c"
}
}| Field | Type | Description |
|---|---|---|
event | string | One of the event names listed above (or "test") |
survey_id | string | UUID of the survey |
data | object | Per-event payload — see the Events table |
Headers
| Header | Description |
|---|---|
content-type | application/json |
x-krafter-event | The event name (e.g. response.completed) |
x-krafter-webhook-id | UUID v4 — regenerated on every attempt, so retries are individually identifiable |
x-krafter-signature | sha256=<hex> HMAC-SHA256 of the raw request body keyed by your webhook secret |
x-krafter-test | Present only when triggered via Test Webhook |
See Webhook delivery → Headers.
Signature verification
Surveys webhooks use the same HMAC-SHA256 envelope as every other Krafter webhook. The full canonical examples (Node.js, Elixir, Python) live on Webhook delivery → Verifying signatures.
Briefly:
javascript
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}Always use the raw bytes of the request body — re-serialising parsed JSON will not match.
Retries and auto-disable
Failed deliveries (any non-2xx, network error, or timeout) are re-enqueued by Oban with a linear backoff of attempt * 15 seconds — 15s, 30s, 45s, 60s, 75s — for up to 5 attempts. After 10 consecutive failures across deliveries, the helper flips active to false and stops attempting new deliveries until you re-enable the webhook.
Surveys webhooks do not currently send an owner-notification email when auto-disable fires — only Forms and Push do today (the helper exposes a webhook_type opt that hasn't been wired into Surveys yet). Monitor failure_count via List Webhooks or the dashboard.
See Webhook delivery → Retry policy and Auto-disable.
Idempotency
Use data.response_id (or data.summary_id / data.survey_id) as your dedup key. The same logical event delivered after a retry carries a new x-krafter-webhook-id — that header identifies a delivery attempt, not the underlying event.
For response.completed, a complete API call against an already-completed response is a no-op and does not re-fire the webhook.
Best practices
- Always verify the signature before processing events
- Respond 2xx within a few seconds — process work asynchronously if you need more time. Slow responses count as failures once the upstream HTTP timeout hits
- Subscribe narrowly — empty
events: []saves your endpoint from chatter you don't want - Plan for
response.completedto fire beforesummary.generated— summaries run on a background job after enough responses have come in. Don't assume both arrive together - Use HTTPS — the schema accepts
http://today but production webhooks should always use HTTPS for confidentiality and integrity