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.status.changed | A survey is activated or paused. |
survey.dismissed | A respondent closes a survey without completing it. |
subscription.plan.changed | A company’s subscription plan changes. |
subscription.payment.failed | A subscription payment attempt fails. |
subscription.trial.ended | A trial subscription ends. |
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.
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"
},
"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
}
}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.
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).