Skip to Content
IntegrationsWebhooks

Webhooks

Get an HTTP callback whenever a respondent finishes a survey, with the full set of answers — no need to call back into the API to fetch the response.

Creating an endpoint

From your project’s Integration page, add a webhook endpoint with a URL (HTTPS only — loopback and private IPs are rejected) and the events it should receive. Your project’s secret key (sk_...) is used as the signing secret — one is created automatically on a plan with Integrations enabled if you don’t already have one — see Server API → Webhook signing with your secret key. On a plan without Integrations, a whsec_... secret is generated instead. Either way, the secret is never returned by create/list/update — use Reveal signing secret when you need it.

Via the API:

POST /api/v1/projects/{projectId}/webhooks Content-Type: application/json Authorization: Bearer {token} { "url": "https://api.acme.com/hooks/1sygnal", "events": ["survey.response.created"] }

Event catalog

EventFires when
survey.response.createdA respondent finishes a survey. There is no partial-response concept — this fires once, on submission.
survey.dismissedA respondent closes a survey without completing it.
survey.question.answeredA respondent answers an individual question — fires per question, independent of completion or dismissal.

Endpoints reject unknown event names on create/update (400) — including any name not in this table, so a typo or a stale integration can’t silently subscribe to nothing.

wasDirectTarget

survey.response.created and survey.dismissed payloads both carry wasDirectTarget, reporting whether the respondent was ever a direct target of the survey via the API’s targeting endpoint. Two things a consumer can’t infer from the field name:

  • Historical, not current. It reflects whether a target row has ever existed for this user and survey — an expired target still reads true.
  • Additive, not exclusive. Targeting only adds recipients on top of whatever a survey’s trigger rules already deliver to. false is the normal value for most respondents in the default automatic trigger mode, since trigger rules deliver to untargeted users too. Only api_only surveys are target-exclusive — there, wasDirectTarget is effectively always true.

clientContext

All three events carry clientContext when the SDK attached one — the same object on survey.response.created, survey.dismissed, and survey.question.answered. It is omitted entirely (not sent as null) when the SDK didn’t send it.

Typical fields: locale, platform, os, sdkVersion. Event-triggered surveys also include triggerContext (eventName plus the properties your app passed to the track() call that showed the survey). SDK-default device/browser fields ($browser, $os, $current_url, and the rest of the auto-attached $-prefixed context) are not copied here — those still go out on the tracked event itself for targeting and analytics. That merge is not scoped to the Every event frequency — any event-triggered survey attaches it. Treat triggerContext.properties as potentially identifying; it is whatever your app passed to track().

Envelope and headers

Every delivery is a POST with this envelope:

{ "id": "019887c1-5a03-7d44-8b91-2c3d4e5f6a7b", "event": "survey.response.created", "timestamp": "2026-08-15T09:41:07.004Z", "payload": { "...": "..." } }

Every timestamp across the API — timestamp here, and every *At field in a payload — is millisecond-precision ISO 8601, always UTC: 2006-01-02T15:04:05.000Z. Parse it with any standard ISO 8601 parser; don’t assume a fixed string length.

HeaderDescription
X-1Sygnal-EventSame as event in the body.
X-1Sygnal-Delivery-IdSame as id in the body. Stable across retries of the same delivery — use it to dedupe.
X-1Sygnal-Signaturesha256=<hex hmac> — HMAC-SHA256 of the raw request body bytes, keyed by the endpoint’s signing secret.

Verifying the signature

The signature is computed over the exact bytes sent — verify against the raw body, not a re-serialized copy of the parsed JSON (key ordering, whitespace, and number formatting can all change the bytes without changing the meaning).

Node.js

import { createHmac, timingSafeEqual } from "node:crypto"; function verify(rawBody, signatureHeader, signingSecret) { const expected = "sha256=" + createHmac("sha256", signingSecret).update(rawBody).digest("hex"); const a = Buffer.from(signatureHeader); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); }

Go

func verify(rawBody []byte, signatureHeader, signingSecret string) bool { mac := hmac.New(sha256.New, []byte(signingSecret)) mac.Write(rawBody) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signatureHeader), []byte(expected)) }

Python

import hmac, hashlib def verify(raw_body: bytes, signature_header: str, signing_secret: str) -> bool: expected = "sha256=" + hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(signature_header, expected)

Delivery semantics

Delivery is at-least-once — a receiver that processes a request and then fails to respond (timeout, crash, network blip) will get it again. Dedupe on X-1Sygnal-Delivery-Id, which is stable across retries of the same delivery.

Deliveries to one endpoint can arrive out of order. Order by payload.submittedAt, not arrival order.

Retries: up to 6 attempts per delivery, isolated per endpoint (a failure on one endpoint never delays or blocks delivery to another). 5xx responses and transport errors (timeouts, DNS failures, connection resets) are retried; any 4xx response is treated as final and not retried. Your endpoint should acknowledge fast and process off the request — the client timeout is 10 seconds.

Localization

surveyName, questionText, and valueLabels arrive in the language the respondent actually saw — the survey render, not a guess. languageCode on the payload names that language.

Key on questionId and value, which are stable identifiers — never on display text, which is meant to be shown to a human, not parsed. A survey with only some questions translated to a given language mixes languages within one payload: each string falls back to the survey’s base language independently, question by question.

Data protection

This payload egresses respondent free text and EMAIL_INPUT answers to a customer-controlled URL over the public internet — that’s a meaningfully bigger surface than the IDs-only shape it replaced. Endpoints should be HTTPS (enforced on create), and access-controlled on your side — anyone who can read requests to that URL can read what your respondents wrote. See Privacy & Consent for how consent and data minimization apply to survey responses generally.

Value shapes by question type

Question typevalue shape
NPS, RATING_STAR, RATING_SCALEinteger
NUMBER_INPUT, SLIDERnumber
TEXT_SHORT, TEXT_LONG, EMAIL_INPUT, DATE_TIMEstring
MULTIPLE_CHOICE_SINGLE, IMAGE_SELECTIONstring (option ID)
MULTIPLE_CHOICE_MULTI, RANKINGarray of option ID strings
BINARYboolean
Matrix types, CONTINUOUS_SUMobject

valueLabels resolves option IDs to their displayed text for every choice-bearing type above (including matrix types) — absent for scalar/text types, since the value is already human-readable there.

Only answered questions appear in answers — a skipped optional question is simply absent, so consumers must tolerate gaps rather than assuming every survey question shows up.

Reference delivery

POST /hooks/1sygnal HTTP/1.1 Host: api.acme.com Content-Type: application/json X-1Sygnal-Event: survey.response.created X-1Sygnal-Delivery-Id: 019887c1-5a03-7d44-8b91-2c3d4e5f6a7b X-1Sygnal-Signature: sha256=3b9f0a1c7e5d84f2a06b1d93c8e47f5a2d1b6c09e8f37a45b2c1d0e9f8a7b6c5
{ "id": "019887c1-5a03-7d44-8b91-2c3d4e5f6a7b", "event": "survey.response.created", "timestamp": "2026-08-15T09:41:07.004Z", "payload": { "responseId": "019887c1-4f2a-7c31-9e08-1a2b3c4d5e6f", "surveyId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b0a", "surveyName": "Post-checkout feedback", "projectId": "01984f22-8c07-7b12-a3d5-9e0f1a2b3c4d", "externalUserId": "user_84213", "anonymousId": "anon_7f3c9d2e18b64a05", "userProfileId": "019885d0-1e44-7a88-b012-3c4d5e6f7a8b", "languageCode": "en", "startedAt": "2026-08-15T09:40:12.480Z", "submittedAt": "2026-08-15T09:41:06.912Z", "clientContext": { "locale": "en-GB", "platform": "web", "os": "macOS", "sdkVersion": "1.4.2", "triggerContext": { "eventName": "checkout_completed", "properties": { "orderId": "ord_84213" } } }, "answers": [ { "questionId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b11", "questionType": "NPS", "questionText": "How likely are you to recommend us to a friend?", "order": 0, "value": 9 }, { "questionId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b12", "questionType": "MULTIPLE_CHOICE_SINGLE", "questionText": "What did you come here to do today?", "order": 1, "value": "019870aa-3b11-7d90-8c44-5f6e7d8c9c01", "valueLabels": ["Complete a purchase"] }, { "questionId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b13", "questionType": "MULTIPLE_CHOICE_MULTI", "questionText": "Which parts of checkout felt slow?", "order": 2, "value": [ "019870aa-3b11-7d90-8c44-5f6e7d8c9c11", "019870aa-3b11-7d90-8c44-5f6e7d8c9c13" ], "valueLabels": ["Address entry", "Payment confirmation"] }, { "questionId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b14", "questionType": "TEXT_LONG", "questionText": "Anything else you'd like us to know?", "order": 3, "value": "Card form kept resetting when I switched tabs." } ], "wasDirectTarget": false } }

The same response from a respondent whose device reported fr-FR, on a survey with supportedLanguages: ["en","fr"], differs only in the localized strings:

{ "languageCode": "fr", "surveyName": "Retour après commande", "answers": [ { "questionId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b12", "questionType": "MULTIPLE_CHOICE_SINGLE", "questionText": "Que veniez-vous faire aujourd'hui ?", "order": 1, "value": "019870aa-3b11-7d90-8c44-5f6e7d8c9c01", "valueLabels": ["Finaliser un achat"] } ] }

survey.dismissed sample

Fires when a respondent closes a survey without completing it:

POST /hooks/1sygnal HTTP/1.1 Host: api.acme.com Content-Type: application/json X-1Sygnal-Event: survey.dismissed X-1Sygnal-Delivery-Id: 019887c2-6b14-7e55-9c02-3d4e5f6a7b8c X-1Sygnal-Signature: sha256=4c0a1b2d8f6e95a3b17c2ea4d9f58a3b2e1c7d0af9048b56c3d2e1f0a9b8c7d6
{ "id": "019887c2-6b14-7e55-9c02-3d4e5f6a7b8c", "event": "survey.dismissed", "timestamp": "2026-08-15T09:45:22.310Z", "payload": { "surveyId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b0a", "projectId": "01984f22-8c07-7b12-a3d5-9e0f1a2b3c4d", "userProfileId": "019885d0-1e44-7a88-b012-3c4d5e6f7a8b", "externalUserId": "user_84213", "dismissedAt": "2026-08-15T09:45:22.310Z", "wasDirectTarget": false, "clientContext": { "locale": "en-GB", "platform": "web", "os": "macOS", "sdkVersion": "1.4.2", "triggerContext": { "eventName": "checkout_completed", "properties": { "orderId": "ord_84213" } } } } }

externalUserId and anonymousId are each omitted entirely (not sent as "") when not applicable — an anonymous respondent carries anonymousId but no externalUserId, and vice versa for an identified one. clientContext follows the same omit-not-null rule when the SDK didn’t attach one.

survey.question.answered sample

Fires per answered question, independent of completion or dismissal. payload.answer uses the exact same shape as one entry in survey.response.created’s answers[] — including valueLabels for choice-type questions:

POST /hooks/1sygnal HTTP/1.1 Host: api.acme.com Content-Type: application/json X-1Sygnal-Event: survey.question.answered X-1Sygnal-Delivery-Id: 019887c3-7c25-7f66-8d13-4e5f6a7b8c9d X-1Sygnal-Signature: sha256=5d1b2c3e9f7f06b4c28d3fb5eaf69b4c3f2d8e1bf0159c67d4e3f2a1b0c9d8e7
{ "id": "019887c3-7c25-7f66-8d13-4e5f6a7b8c9d", "event": "survey.question.answered", "timestamp": "2026-08-15T09:40:45.123Z", "payload": { "surveyId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b0a", "projectId": "01984f22-8c07-7b12-a3d5-9e0f1a2b3c4d", "userProfileId": "019885d0-1e44-7a88-b012-3c4d5e6f7a8b", "externalUserId": "user_84213", "answeredAt": "2026-08-15T09:40:45.123Z", "answer": { "questionId": "019870aa-3b11-7d90-8c44-5f6e7d8c9b11", "questionType": "NPS", "questionText": "How likely are you to recommend us?", "order": 0, "value": 9 }, "clientContext": { "locale": "en-GB", "platform": "web", "os": "macOS", "sdkVersion": "1.4.2", "triggerContext": { "eventName": "checkout_completed", "properties": { "orderId": "ord_84213" } } } } }

Delivery log

GET /api/v1/projects/{projectId}/webhooks/{webhookId}/deliveries returns the delivery history, paginated:

{ "data": [ { "id": "...", "webhookEndpointId": "...", "eventType": "survey.response.created", "statusCode": 200, "responseTimeMs": 143, "retryCount": 0, "errorMessage": null, "deliveredAt": "2026-08-15T09:41:07.100Z" } ], "hasMore": false }

Deliveries older than 30 days are pruned automatically. Payloads over 16KB are stored truncated in the log (the full body is still sent on the wire — only the audit copy is capped).