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.status.changedA survey is activated or paused.
survey.dismissedA respondent closes a survey without completing it.
subscription.plan.changedA company’s subscription plan changes.
subscription.payment.failedA subscription payment attempt fails.
subscription.trial.endedA 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. 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.

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" }, "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).