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_hoursper dispatch to widen or narrow it;0disables this layer for that dispatch only.
skippedRecent,
so the partition arithmetic is:
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.
Landing page 404 or “link expired”
The invitation link targetsGET /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:
- 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.
- 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.
- 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.
/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_atand 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
scoreandcommenton the same response row. It does not create a second row and does not moveresponded_at. - Client retries and double-taps collapse onto the same row through the same write, so a retrying integration cannot double-count a response.
- 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.dispatchorsurvey.openevent. 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 fromsurvey.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.
- Confirm the endpoint subscription includes the exact event name (catalog above); a wildcard added before the survey events existed may not cover them.
- 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.
- 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 (apt-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:- The survey id and the dispatch timestamp (UTC).
- The dispatch result partition: audience size,
eligible,skippedRecent,skippedFatigue. - 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.
- For webhook problems: the endpoint id, the subscribed event list, and the delivery ids from the webhook log.
- The
fatigue_window_hoursvalue 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
skippedRecentagain, 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.openevent as a delivery bug; the event does not exist by design (see Webhook silence above).