> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

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

> Runbooks for the survey and Voice-of-Customer subsystem: why a dispatch sends to fewer contacts than the audience, how to read the same-survey and fatigue skip partitions, what a 'link expired' landing page means, first-response-wins submission semantics, survey webhook silence, and locale and push dispatch quirks.

# 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](/concepts/survey-lifecycle-model); the operational
guide is [Surveys & VoC](/guides/surveys-voc).

## Symptom to gate matrix

| Symptom | Gate that produced it | First check | Fix |
| - | - | - | - |
| Dispatch sent to fewer contacts than the audience | Frequency-cap partition (same-survey dedupe, cross-survey fatigue) | The `skippedRecent` / `skippedFatigue` counts on the dispatch result | Wait out the window, or raise `fatigue_window_hours` for the next dispatch |
| Landing page shows "link expired" or 404s | Signed-token verification on `GET /public/surveys/:token` | Whether the token string survived transit intact | Re-dispatch a fresh invitation to that contact |
| A second submission did not create a second response | First-response-wins upsert | `responded_at` on the response row | Read the single row; a re-submit updates it in place |
| An expected `survey.*` webhook never arrived | Event bindings on your webhook endpoint | Which events actually exist and which you subscribed | Subscribe the real event names (table below) and verify signatures |
| Recipient saw the base language, not their locale | Locale resolver fallback chain | The contact's locale tag against the survey's translation map | Add the locale tag or its primary subtag to the survey translations |

## 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:

```text theme={null}
audience = eligible + skippedRecent + skippedFatigue
```

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.

## Landing page 404 or "link expired"

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](/concepts/survey-lifecycle-model) 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](/webhooks/events); the survey rows in it are:

| Event | Fires when |
| - | - |
| `survey.response.received` | A response is recorded against a pending survey response, whether by landing-page submission or by inbound numeric reply |
| `survey.response.detractor` | A recorded response lands at detractor level (NPS or CSAT at or below 6), for closed-loop escalation |
| `survey.csat.response_recorded` | A post-call IVR CSAT response is captured (voice surveys) |
| `survey.nps.response_recorded` | A post-call IVR NPS response is captured (voice surveys) |

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](/webhooks/troubleshooting-signature-failures)
   plus [Webhook deliveries](/troubleshooting/webhook-deliveries) cover
   the two failure classes.
3. Verify signatures per
   [Webhook security](/webhooks/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](/troubleshooting/push-token-expiry) and
[Push broadcast caps](/troubleshooting/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).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.