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
| Event | Fires when |
|---|---|
survey.response.created | A respondent finishes a survey. There is no partial-response concept — this fires once, on submission. |
survey.dismissed | A respondent closes a survey without completing it. |
survey.question.answered | A 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.
falseis the normal value for most respondents in the defaultautomatictrigger mode, since trigger rules deliver to untargeted users too. Onlyapi_onlysurveys are target-exclusive — there,wasDirectTargetis effectively alwaystrue.
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.
| Header | Description |
|---|---|
X-1Sygnal-Event | Same as event in the body. |
X-1Sygnal-Delivery-Id | Same as id in the body. Stable across retries of the same delivery — use it to dedupe. |
X-1Sygnal-Signature | sha256=<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 type | value shape |
|---|---|
NPS, RATING_STAR, RATING_SCALE | integer |
NUMBER_INPUT, SLIDER | number |
TEXT_SHORT, TEXT_LONG, EMAIL_INPUT, DATE_TIME | string |
MULTIPLE_CHOICE_SINGLE, IMAGE_SELECTION | string (option ID) |
MULTIPLE_CHOICE_MULTI, RANKING | array of option ID strings |
BINARY | boolean |
Matrix types, CONTINUOUS_SUM | object |
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).