Skip to main content

Troubleshoot survey dispatch, signed links, and frequency-cap skips

Survey dispatch runs through governance gates before any message goes out: a fixed same-survey dedupe window, a tenant-settable cross-survey fatigue window, signed landing tokens with a 30-day lifetime, and first-response-wins submission. Each gate has one observable symptom and one correct fix. This page maps the symptom to the gate. The lifecycle itself is described in The survey lifecycle; the operational guide is Surveys & VoC.

Symptom to gate matrix

Why is my dispatch count smaller than my audience?

Every dispatch path partitions the resolved audience into three lists before a single message is sent:
  • eligible — contacts who receive this dispatch.
  • skippedRecent — contacts sent this same survey within the fixed 24-hour dedupe window. This layer always applies and cannot be disabled.
  • skippedFatigue — contacts sent any survey (this one or another program) within the cross-survey fatigue window, 72 hours by default. Set fatigue_window_hours per dispatch to widen or narrow it; 0 disables this layer for that dispatch only.
A contact present in both skip sets is counted once, in skippedRecent, so the partition arithmetic is:
Worked example: you dispatch an NPS survey to an audience of 1,000 contacts. 150 of them received this same survey yesterday, and a further 200 received a different CSAT survey two days ago (inside the 72-hour fatigue window). The dispatch fans out to 650 contacts, and the dispatch result reports skippedRecent: 150, skippedFatigue: 200. Nothing failed; the governors did exactly what they exist for. The fatigue window is tenant-owned: the audience bulk-send dialog in the dashboard exposes the field, and the dispatch API accepts fatigue_window_hours directly. The 24-hour same-survey dedupe is a fixed platform protection and has no dial. If the shortfall is unexpected, compare the partition counts on the dispatch result against your recent send history before suspecting a delivery fault. Skips happen before provider routing, so a frequency-cap shortfall never appears in delivery or DLR logs. The invitation link targets GET /public/surveys/:token. The token is a signed, self-contained credential carrying the survey, the contact, the tenant, the send time, and an expiry 30 days after dispatch. Verification fails closed: a malformed, tampered, or expired token renders a generic “link expired” page, and the endpoint never reveals whether a survey exists. Treat every “link expired” page as one of three causes:
  1. The token string was mangled in transit. The token is a long base64url payload plus signature; a URL shortener, a line-wrapping mail client, or a copy-paste that drops trailing characters breaks the signature. If only some recipients report the problem and they share a mail client or a shortened link, this is the cause. Send the link untouched, or use the push channel, whose tap target is built by the platform and cannot be mangled.
  2. The 30-day lifetime lapsed. Email opens tail off over weeks; a link tapped on day 31 is expired by design. Re-dispatch a fresh invitation to the non-responders rather than re-sharing the old link.
  3. The token was edited or assembled by hand. Building a link from a survey id alone will never verify; only the token minted at dispatch time is valid.
A true HTTP 404 (not the “link expired” page) means the path itself is wrong: the route is /public/surveys/:token, so check that a proxy or rewrite rule is not stripping the prefix, and that the link was not retyped. Note that an already-answered link does not expire into an error: a recipient who re-taps after answering sees the thank-you page. A “link expired” page therefore always points at the token, not at the response state.

Second submission, expected 409, or “response not updating”

Submission is first-response-wins and idempotent by design, and it never answers a repeat with a 409 conflict:
  • The first submission stamps responded_at and fires the downstream side effects (contact trait writeback, the CDP event, detractor escalation) exactly once.
  • A later submission on the same signed link, while the token is still valid, updates score and comment on the same response row. It does not create a second row and does not move responded_at.
  • Client retries and double-taps collapse onto the same row through the same write, so a retrying integration cannot double-count a response.
Consequences when your integration assumed otherwise:
  • If you coded a retry loop that expects a conflict status on duplicate submission, you will instead see a success that updates the answer in place. Key your reconciliation on the response row, not on status codes.
  • If a correction “did not update”, the token has usually expired (see the previous section); an expired token cannot submit at all.
  • To read when a respondent first answered, use the response’s responded_at; it is the first-response timestamp the VoC rollups and scorecards anchor to. Aggregate analytics intentionally keep the first response, so a corrected score changes the average but not the response count or the first-response timing metrics.

Webhook silence

The survey subsystem emits a small, deterministic set of webhook events; several names integrations guess at do not exist. The live catalog is Webhook events; the survey rows in it are: Two silences are by design, not by defect:
  • There is no survey.dispatch or survey.open event. Dispatch outcomes are reported synchronously in the dispatch result (the eligible / skipped partition above), and a landing-page render is deliberately not an event: the public landing endpoint is unauthenticated and must not leak open tracking. If a flow needs “recipient engaged”, drive it from survey.response.received.
  • Events fire once per response. The first-response-wins discipline applies to side effects too, so re-taps and retries never re-emit.
If an event that should have fired did not:
  1. Confirm the endpoint subscription includes the exact event name (catalog above); a wildcard added before the survey events existed may not cover them.
  2. Inspect deliveries in the dashboard webhook log; a signature mismatch or DNS failure shows there, and Troubleshooting signature failures plus Webhook deliveries cover the two failure classes.
  3. Verify signatures per Webhook security; never act on an unverified payload while debugging.

Push and locale dispatch quirks

Locale. Copy localizes per recipient at dispatch time with this resolution order: exact locale tag match (case-insensitive), then primary-subtag match (a pt-PT contact can receive a pt translation), then the survey’s default locale, then the base copy. The outbound message, the public landing form, and the SDK all resolve through the same chain, so one recipient sees one language everywhere. The render-side symptom of a missing or invalid locale is silent fallback: the contact simply receives the base copy, with no error at dispatch. If a market segment renders in the wrong language, check the contact’s locale tag spelling first, then whether the survey translations include that tag or its primary subtag. Push. The push channel skips the per-recipient messaging path and fans out to the contact’s registered device tokens through the platform push infrastructure, so push opt-outs, dead device tokens, and per-device routing all apply exactly as they do for any push send (see Push token expiry and Push broadcast caps). The tap target is the same signed landing page email uses, so every token rule on this page applies unchanged. A contact with no live device token is skipped by the fan-out before the survey layer; that shortfall shows in push delivery counts, not in the survey partition.

Escalation checklist

Before opening a support ticket for a survey dispatch or response problem, collect:
  1. The survey id and the dispatch timestamp (UTC).
  2. The dispatch result partition: audience size, eligible, skippedRecent, skippedFatigue.
  3. For a landing-page problem: the affected contact id and the mail client or link path the recipient used. Never include the full token; the first characters are enough, and a full token is a live credential.
  4. For webhook problems: the endpoint id, the subscribed event list, and the delivery ids from the webhook log.
  5. The fatigue_window_hours value used on the dispatch.

What NOT to try

  • Do not re-dispatch the same survey to the skipped contacts within 24 hours to force delivery. The same-survey dedupe will partition them into skippedRecent again, and each attempt spends audience goodwill for zero sends.
  • Do not look for an API switch to disable the 24-hour same-survey dedupe; it is a fixed protection. The tenant-owned dial is fatigue_window_hours, which governs only the cross-survey layer, per dispatch, from the bulk-send dialog or the dispatch API.
  • Do not rebuild or truncate landing tokens, and do not route them through a URL shortener you do not control; any byte change fails the signature and renders as “link expired”.
  • Do not treat a missing survey.open event as a delivery bug; the event does not exist by design (see Webhook silence above).