New Popup — Platform API Specification
Three endpoints turn the prototype into product: serve survey config, accept responses, count events. Classic Popup is untouched; everything here lives in a new /pop-up/v2/ namespace.
0. Shared principles
- Hosts: the existing regional survey origins —
survey.eu.customergauge.com,survey.us.…,survey.au.…. Same CSP story as today. - Trust model: all three endpoints are public and unauthenticated, like today's pop-up survey delivery. Config is read-only and contains nothing secret. Writes are rate-limited and validated; a browser widget cannot hold credentials, so security comes from validation, limits and idempotency, not auth.
- CORS:
Access-Control-Allow-Origin: *. POST bodies must be accepted asapplication/jsonandtext/plain(whatnavigator.sendBeaconsends without a preflight). - Versioning: every payload carries
"v": 2. Schema changes bump it. - Classic untouched: nothing under the existing
/pop-up/paths changes behaviour.
1. Survey config
GET /pop-up/v2/config/{surveyId}.json
Returns the survey's behaviour as JSON. Written by Survey Builder on Save (the evolution of the Distribution tab: trigger, audience, frequency, page fields join today's frequency + delay). The widget fetches it on every page load; this is what makes marketer changes live in minutes with no website deploy.
Response — 200, worked example
{
"v": 2,
"surveyId": "f8988988-14da-4124-a027-ff4fcd4ffd12",
"active": true,
"mode": "popover",
"position": "bottom-right",
"theme": { "primary": "#1a73e8", "radius": 12 },
"language": "auto",
"trigger": { "type": "scroll", "percent": 50 },
"audience": {
"urls": { "include": ["/checkout/*"], "exclude": [] },
"devices": ["desktop", "mobile"],
"sample": 25,
"attributes": [{ "key": "page_views", "op": "gt", "value": 2 }]
},
"frequency": { "policy": "until_submitted", "cooldownDays": 30 },
"questions": [
{ "id": "nps", "type": "rating", "text": "How likely…?", "scaleMin": 0, "scaleMax": 10,
"labelLow": "Not at all likely", "labelHigh": "Extremely likely" },
{ "id": "comment", "type": "text", "text": "What is the main reason?" }
],
"thankYou": { "title": "Thank you!", "message": "…" },
"fields": {
"order_value": { "source": "dataLayer:ecommerce.value", "dest": "segment_c" },
"utm_campaign": "query:utm_campaign"
},
"submitUrl": "https://survey.eu.customergauge.com/pop-up/v2/responses",
"eventsUrl": "https://survey.eu.customergauge.com/pop-up/v2/events"
}
- Full field-by-field schema:
src/types.tsin the prototype is the reference implementation the widget parses.fieldsvalues are either a source token (→ flexible attributes store) or{source, dest}(→ named CG field/segment). - Deactivated or deleted survey: return 200 with
{"v": 2, "surveyId": "…", "active": false}— the widget goes silent. Keeping it a 200 keeps CDN behaviour simple and makes deactivation propagate on the same TTL as any other change. - Caching:
Cache-Control: public, max-age=60, stale-while-revalidate=300. At those numbers a survey on a million-pageview site costs ~1 origin hit per minute per region. Optional nicety: purge on Save for instant propagation. - Limits: response ≤ 32 KB. No PII, no account data beyond what the survey itself displays.
2. Response ingestion
POST /pop-up/v2/responses
Accepts a completed (or partial) response and creates the survey record on arrival, using the existing anonymous-survey mechanism: sent date = completed date = now(). No pre-created non-response record exists or is required.
Request — what the widget sends today
{
"v": 2,
"surveyId": "f8988988-14da-4124-a027-ff4fcd4ffd12",
"responseToken": "6f3f2c1e-8a37-4b1e-9d3e-2f6a1c9e4b70",
"partial": false,
"userId": "contact-4711",
"attributes": {
"page_views": 7, "session_page_views": 3,
"order_value": 1250, "utm_campaign": "q3_launch",
"plan": "enterprise", "account_name": "Acme Fabrication BV"
},
"answers": { "nps": 9, "comment": "Fast delivery, great support" },
"url": "https://shop.example.com/checkout/complete",
"submittedAt": "2026-07-07T14:31:09.144Z"
}
Server behaviour, in order
- Validate: known surveyId, answer ids exist in the survey, size ≤ 32 KB, ≤ 100 attribute keys, scalar values ≤ 256 chars.
- Idempotency:
(surveyId, responseToken)is unique. A repeat is a no-op returning 202 — this makes sendBeacon/fetch retries and double-clicks safe. Tokens are client-generated UUIDs. - Create the record via the anonymous-survey path: sent date = completed date = now().
partial: truecreates it flagged partial; a later request with the same responseToken andpartial: falsecompletes it (mirrors today's partial-completes setting). - Contact matching — opt-in, not automatic: match/create a contact only when identity fields (email / phone / reference) are present in the mapped fields and the account allows it. Otherwise the response stays anonymous and consumes no contact quota. This single rule fixes the junk-contacts problem.
- Tier 1 routing: using the survey's
fieldsconfig, write attributes with adestto that CG field/segment; write answers to their mapped CG fields (question→field mapping set in Survey Builder). - Tier 2 storage: persist the remaining attributes object with the response — queryable JSON column per the engineering overview (registry + promotion); a memo-field shim is an acceptable bridge. Drop non-allowlisted PII keys at ingestion.
Status codes
| Code | Meaning |
|---|---|
202 | Accepted (including idempotent repeats). Processing may be async; the widget never waits on reporting. |
400 | Malformed / unknown survey / failed validation. Body: {"error": "…"}. |
413 / 429 | Too large / rate-limited (per IP + surveyId; generous — a human answers a survey once). |
3. Events (impressions & funnel)
POST /pop-up/v2/events
Fire-and-forget batched counters. No records are created — this replaces "a non-response record per display" with cheap aggregates, while keeping the numbers you rely on.
{
"v": 2,
"surveyId": "f8988988-14da-4124-a027-ff4fcd4ffd12",
"events": { "displayed": 1, "started": 1, "completed": 1 },
"url": "https://shop.example.com/checkout/complete"
}
- The widget accumulates counts in memory and flushes once via
sendBeacononpagehide/tab-hide. Event names:displayed,started,completed,closed. - Server increments per-survey, per-day counters. Suggested storage: one row per (surveyId, date) with four integer columns — enough for the funnel: displayed → started → completed, and abandonment.
- Continuity:
displayedalso increments the existing per-survey page-hit counter, so the current crude response-rate number keeps working unchanged during and after migration. New response rate (better): endpoint-2 records ÷displayed. - 202 always (or 204); aggressively rate-limited; malformed bodies silently dropped. Losing a beacon is acceptable — these are statistics, not records.
Rollout & effort shape
- Order: endpoint 1 (config) unblocks everything and is read-only; endpoint 2 reuses the proven anonymous-survey path plus routing; endpoint 3 is a counter table. Tier 2 JSON column + registry can trail as phase 3 with the memo shim in between.
- Pilot: feature-flag per account. One friendly customer, one survey, GTM install, compare endpoint-3 funnel numbers against Classic's counters for a fortnight.
- Widget: already speaks this exact contract (tokens, payload shapes, beacon batching) against stubs — point
submitUrl/eventsUrlat staging and Phase 1 integration testing starts the same day.
identify/params); (2) whether config Save purges the CDN or we accept the 60s TTL; (3) confirm the anonymous-survey record path exposes partial-complete semantics; (4) naming — /pop-up/v2/ vs a fresh /widget/ namespace.