-
START HERE IF A QUOTA COUNTS BUT NEVER BLOCKS: check ActionInfo.
QuotaAction: "EndCurrentSurvey" only NAMES the action. The nested
ActionInfo object is what actually FIRES it. A quota written with an
empty stub —
"ActionInfo": {"Type": "BooleanExpression"}
— counts every matching respondent flawlessly, forever, and acts on none
of them: no termination, no screen-out, no error at write time, at publish
time, or at runtime, and the builder UI renders the quota as normal. The
platform's own serialization is:
"ActionInfo": {"0": {"0": {"ActionType": "EndCurrentSurvey",
"Type": "Expression",
"LogicType": "QuotaAction"},
"Type": "If"},
"Type": "BooleanExpression"}
ActionType must repeat the QuotaAction value. This was shipped as an
empty stub in a homegrown API client and made every quota it ever created
inert across multiple live fielding surveys — the true reason quotas
"didn't work", after days of chasing logic-dialect theories that were all
downstream of it. If a quota's count is incrementing, its matching logic
is already correct and the problem is not the logic; go straight to
ActionInfo.
-
Proven by controlled before/after, and this is the method to reuse.
Do NOT try to diagnose quota enforcement on a clone of the real
instrument — geo gates, duplicate-device gates, anti-automation paradata
and session-resume cookies will each eject or short-circuit the walk, and
stripping them piecemeal corrupts the flow (all of this was tried, and
burned hours). Instead build a minimal disposable survey from scratch:
three quota-bearing questions on page 1, one marker question on page 2
(reaching page 2 is the "not blocked" signal — a single-page survey cannot
demonstrate blocking at all, because the respondent has already answered
everything by the time the quota evaluates), plus TWO quotas: one the walk
matches and one differing in a single condition as a negative control.
Walk it once to fill the match quota, then walk again and observe. The
control must stay at 0 — that is what proves the multi-condition AND is
sound rather than collapsing. Change ONE variable per walk. In the
reference case the immediately-preceding walk already had the group's
Selected flag, canonical dialect and EndSurveyOptions all in place and
still reached page 2; populating ActionInfo alone flipped the identical
walk to terminating at page 1.
-
Ruled out along the way — do not re-chase these (each was a plausible
theory that cost real time): the quota group's Selected flag (setting it
True to match a working reference changed nothing on its own); the
presence or absence of EndSurveyOptions (worth having for the QuotaMet
flag, but not what makes a quota fire); and Occurrences: 0 as a way to
express "already full" — that IS wrong and no platform-authored quota uses
it (minimum observed across 49 reference quotas was 16), but with a broken
ActionInfo no limit value of any kind would have blocked anyone. Use
Occurrences: 1 when a cell is already over target and you want it shut:
the first matching respondent tips it to full and every subsequent one is
blocked.
-
The actual root cause of "compound quota logic never fires" is a
hand-authoring dialect mismatch, not a platform limit on condition count —
and getting this wrong once already cost a full day of live-fielding churn
across two surveys, so read this one carefully. Qualtrics's own
internally-generated quota JSON uses "Conjuction" (not "Conjunction" —
a legacy misspelling baked permanently into the schema) as the key joining
multiple conditions, and "q://{QID}/ChoiceTextEntryValue" (not
"q://{QID}/TextEntry") as the operand/locator for a numeric comparison
against a text-entry question, with ChoiceLocator duplicating the same
string. Hand-write either one with the "obviously correct" English
spelling or the intuitive-looking locator and the quota accepts the write
(200 OK), reads back exactly as sent, renders correctly in the builder UI's
condition editor, and then matches nobody, ever, with no error anywhere —
because the engine simply doesn't recognize the key/locator and silently
drops that half of the condition. This produced the exact symptom pattern
that looks like "compound conditions never fire": single-condition quotas
(no Conjuction needed) counted correctly the whole time; every
age-range and multi-choice-OR quota sat at zero, on multiple live
production surveys, for over a week, undetected. The fix, once
diagnosed, is to write compound conditions in the correct dialect, not to
avoid them. A same-day rebuild using the verified dialect — including
three- and four-condition flat AND groups spanning multiple different
QuestionIDs (age range AND gender-selected AND region-selected in one
group) — fired correctly and counted real respondents within the hour.
Do not hand-guess this dialect from documentation or from what "looks
right." Find a real, platform-exported QSF from the same account (or ask
the user for one — even an unrelated old survey works) and diff your
generated Logic block against its quota objects byte-for-byte before
trusting anything with more than one condition; this is the single highest-
leverage check available and takes minutes. Absent a reference file, the
fallback is the advisor-consult pattern: hand a second, independent model
the full evidence trail (what fired, what didn't, exact JSON of both) and
ask it to reason from first principles rather than guessing again yourself
— that is what actually surfaced this dialect mismatch after multiple
failed self-directed attempts.
-
LogicType: "EmbeddedField" quota conditions take the BARE field name as
LeftOperand, not the e://Field/... piped reference — and getting this
wrong is silent, like every other quota-dialect error. A platform-authored
Cross quota carries {"LogicType": "EmbeddedField", "LeftOperand": "gc", "Operator": "EqualTo", "RightOperand": "1"} — just "gc". Writing
"e://Field/gc" (the form that is correct everywhere else in Qualtrics:
Branch logic, display logic, piped text, redirect URLs) produces a quota
that accepts the write, reads back intact, and matches nobody. This was
learned expensively: a Branch-precompute design (merge a value into one
flat embedded field, then quota on equality) was built at ~130 quota
objects across two live surveys, with the field VERIFIED correctly
populated in the response export for real respondents, and every one of
those quotas read count: 0 — because all of them used the e://Field/
prefix. Do not conclude from a failure like that that embedded-field quota
logic is unsupported; check the operand format against a platform-authored
example first. Note also that when the goal is an interlock, LogicType: "Cross" (below) is the native mechanism and usually beats precomputing a
merged field at all.
-
LogicType: "Cross" is Qualtrics's native interlock quota, and it is a
different object shape from Simple — know which one you are reading.
A Simple quota is one cell: Logic is a single expression tree (a dict)
and Occurrences is that cell's absolute target. A Cross quota is a whole
grid: Logic is an ARRAY of logic sets, the engine crosses the sets to
generate cells, and Occurrences is the TOTAL across the grid. In a Cross
quota the Conjuction key does not hold "And"/"Or" — it holds that
condition's percentage allocation ("27%", "29%"), and each cell's
effective target is Occurrences x the product of its shares. So the same
misspelled key means two completely different things depending on quota
type; do not pattern-match one onto the other. Choosing between them:
Cross expresses an age x gender x region interlock as ONE object instead
of hundreds, which is decisive when building a grid up front. But because
Cross targets are percentages of a total, it is poorly suited to rebasing
a partially-collected field — "this cell has 33 slots left of 473" is
trivial as a Simple quota's absolute Occurrences: 33 and awkward as a
share of a total that is already half filled. Build with Cross; repair
mid-field with Simple.
-
Sidestep the whole range-comparison problem at design time by asking age
as a categorical band question rather than a numeric text entry. A
platform-authored reference survey that quotas cleanly on age does it with
a multiple-choice item (18-29 / 30-39 / 40-49 / 50-59 / 60+) and plain
Selected conditions — no ChoiceTextEntryValue locator, no
>=/<= pair, no compound condition, nothing to get wrong. If the
instrument is not yet fielded and the analysis does not need exact age,
this is strictly the safer design.
-
For a bilingual/multi-arm instrument, don't reflexively split every
marginal quota by arm. If one arm carries the overwhelming majority of
traffic (check the actual split from the response export, don't assume),
splitting age/gender/etc. into one quota per arm doubles the object count
and produces a nonsensical-looking result on screen (a demographic quota
that appears to depend on survey language). Fold the minority arm into the
majority arm's quota instead — check only the majority-language question,
size it to the full combined remaining target — and accept that the
minority arm isn't independently capped by that specific quota. Disclose
the tradeoff; don't build the split by default.
-
Before trusting ANY quota with more than a trivial condition, prove the
engine actually fires it: create it, then either drive one real or preview
response through the matching path and confirm count increments, or —
cheaper — diff its Logic shape against a quota on the same live survey
that is already demonstrably counting; identical shape, live proof either
way. Note preview/import responses are not reliable for this: a response
created via POST .../responses (import) does not trigger quota
evaluation at all — that's expected, uninformative behavior, not a signal
either way.
-
A quota's count never back-counts responses collected before the quota
existed. It only increments on new submissions from creation forward.
On a survey that has been fielding for a while, a freshly created or
freshly fixed quota reading 0 or low is not evidence it's broken — and,
the more dangerous direction, it is also not evidence a previous broken
quota didn't already let the sample run uneven. Either way, count cannot
tell you the true current composition of an already-fielding survey.
Pull the actual response export (POST .../export-responses) and recompute
fill directly from respondents' real answers against your target grid —
that's the only number that reflects who has actually been collected, and
it's required reading before reporting "verified" on any quota fix applied
mid-field.
-
Quota creation's group auto-assignment is flaky, not just
"always goes to the first group" — confirmed empirically across two
surveys built with near-identical scripts. There is no field to target a
specific group on write. In one run, three sequential create_quota_group
calls (Age, Gender, Region) resulted in Gender's quotas silently landing in
the Age group while Region correctly got its own; in another run on a
different survey, all three groups' quotas landed in the very first group
regardless of creation order. Do not assume any particular assignment
pattern, and do not rely on the UI's "Move to…" menu for anything beyond a
handful of objects — it does not scale. Instead: create every quota first
(accept whatever group it lands in), THEN read back the actual membership
and fix it programmatically via the group PUT below. Order matters when
fixing it: a quota already listed in group A's membership cannot be added
to group B's membership directly — the API returns ESDEF44 ("already
exists in Quota Group X"). PUT the source group first with a shrunk
membership list (removing the quotas you're about to move), THEN PUT the
destination group with them added. Verify final state by reading back every
group's membership and matching quota names against your intended
structure — the group's own Name field is not proof its Quotas array
is what you think it is.
-
The quota-group update endpoint is a full replace: omit the quotas array
and the group's membership is silently wiped. Always resend the complete
membership plus any fields the API requires on write but omits from its own
list payload (e.g. a match-mode flag) — write-shape and read-shape are not
the same contract.
-
Choice-based quota conditions need both the operand the evaluation engine
reads AND the locator the editor UI renders its dropdown from. Write only
the operand and the condition still works but displays as an empty
"Select Choice…" in the UI — and a later UI-side save of that blank state
can overwrite live quota logic with nothing.
-
Confirm the exact operator enum the API expects (vendors sometimes reject a
plausible-looking synonym) rather than assuming from REST convention.
-
Give every hard quota its own EndSurveyOptions at build time. A quota
created without one inherits the survey-level termination settings, which
works — the respondent still exits and still hits whatever redirect the
survey-level EOSRedirectURL resolves to — but the resulting response row
carries no QuotaMet flag, so afterwards you cannot tell a
quota-terminated respondent from any other early exit except by inferring
it from their answer pattern. On an instrument that also produces
consent-refusal and screen-out rows of the same shape (demographics
answered, no outcome data), that inference gets genuinely fiddly. The
platform's own serialization for a hard quota looks like:
"EndSurveyOptions": {"EndingType": "Advanced",
"ResponseFlag": "QuotaMet",
"Screenout": "Yes", "IgnoreResponse": "Yes",
"AnonymizeResponse": "Yes", "CountQuotas": "No",
"SurveyTermination": "Redirect",
"EOSRedirectURL": "<vendor quota-full URL>"}
Note this supersedes an earlier claim in this skill that the API accepts
no per-quota redirect — a platform-exported reference survey carries
EOSRedirectURL inside EndSurveyOptions on its quota objects, so the
quota-full URL can live on the quota itself rather than being bracketed in
the flow. Treat the old claim as untested rather than true; it dates from
the same period as the compound-logic misdiagnosis above.
-
The flow-bracket pattern remains the right retrofit when quotas are
already live without EndSurveyOptions: set an embedded field to the
quota-full exit URL immediately BEFORE the block holding the quota-bearing
questions, reset it to the screen-out URL immediately AFTER, and have the
survey-level end-of-survey redirect read that field. Verify by mapping flow
indices — the setter must precede the block, the reset must follow it. This
gets the vendor disposition right (which is the billing-relevant half) even
though the response row stays unflagged. Retrofitting EndSurveyOptions
onto many live quota objects is a mass mutation on a fielding survey; if the
bracket is already correct, the remaining benefit is forensic tidiness only,
and is usually not worth the write.
-
Quota counts can retain stale values after response deletion even when the
deletion call requests a decrement. Before a FIRST fielding wave, zero the
counters explicitly rather than trusting the decrement flag; mid-study,
reconcile in-progress sessions and prior-wave records first — resetting a
live counter is destructive and needs explicit authorization.
-
Quota-list endpoints paginate at a small page size — always follow the
next-page cursor, or an audit silently covers only the first page of quotas.