Switch
Switch — exposures
POST /api/switch/exposures — record per-call flag exposures for honest experiment math. Up to 1000 rows per request, append-only, deduplicated server-side per session.
Exposures record the moment a user actually evaluated a flag — distinct from "the user was assigned a variant by the handshake." That distinction is what lets experiment math separate assigned populations from exposed populations.
The web SDK posts exposures automatically on every getFlag(...) / getVariant(...) call. Mobile / server SDKs use handshake-level exposures by default but can manually post here when the surface needs precision (e.g. reportExposure calls).
For the concept, see Exposure tracking.
Authentication
Required header: x-api-key: sk_live_… or x-api-key: sk_test_…. See Authentication.
Request body
{
"exposures": [
{
"flag_key": "new_checkout",
"distinct_id": "user_123",
"anon_id": "anon_a3b9ff",
"value": true,
"variant": null,
"reason": "rollout",
"app_version": "2.4.1",
"platform": "ios",
"sdk": "@sankofa/[email protected]",
"ts_ms": 1715183942483
}
]
}exposuresarray<object>Requiredexposures[].flag_keystringRequiredexposures[].distinct_idstringRequiredexposures[].anon_idstringexposures[].valueboolean | stringexposures[].variantstringexposures[].reasonstringexposures[].app_versionstringexposures[].platformstringexposures[].sdkstringexposures[].ts_msnumberExample request
curl -X POST https://api.sankofa.dev/api/switch/exposures \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_..." \
-d '{
"exposures": [
{
"flag_key": "new_checkout",
"distinct_id": "user_123",
"value": true,
"reason": "rollout",
"ts_ms": 1715183942483
},
{
"flag_key": "checkout_redesign",
"distinct_id": "user_123",
"value": "treatment_a",
"variant": "treatment_a",
"reason": "variant_assigned",
"ts_ms": 1715183942483
}
]
}'Response
{
"accepted": 2
}accepted reflects rows that passed validation and queued for write. Rows skipped due to missing flag_key / distinct_id or out-of-window timestamps don't count.
Validation
The handler accepts as many valid rows as it can:
Auth + rate limit
Same as ingestion endpoints (see Authentication and Rate limits).
Body size
Max 1000 entries per request → returns
413if exceeded. Empty array →200 {accepted: 0}.Per-row checks
flag_keynon-empty,distinct_idnon-empty,ts_mswithin ±7 days of server clock. Rows failing any of these are silently dropped.Append-only write
Surviving rows are appended to ClickHouse. The handler does not block on the write — the response returns as soon as the row is queued.
Server-side deduplication
The engine deduplicates exposures per session per (flag_key, value, variant) tuple. If the same web user fires getFlag("new_checkout") 100 times in one session, only one exposure row exists in storage for that session.
If the value or variant changes mid-session (rare, typically because of a halt), a new exposure row is written for the new tuple.
Append-only semantics
Exposures are append-only. Retries are safe — sending the same exposure twice doesn't create duplicates (server-side dedup) but does count toward your rate limit.
For SDK retry backoff: the recommended pattern is exponential backoff with jitter on 429 and 503. Don't retry on 200 even if your local logic suggests the row should have shipped — the engine accepted it.
Per-platform scope
| Platform | Exposure flow |
|---|---|
Web (@sankofa/switch) | Auto-records every getFlag / getVariant call, batched every 5 s. |
| Mobile (Flutter, RN, iOS, Android) | Handshake-level by default — one exposure per (key, session) on handshake. Optional manual reportExposure(...) from your render layer for precision. |
| Server (Node, Go, Java, Python) | No exposure tracking by default. Server-side flag evaluation is per-call; exposures don't naturally fit. Use the Web exposure model (manual reportExposure) only if you have a long-lived process owning a single user's session. |
See Exposure tracking for the deeper rationale.
Quotas + rate limits
- Exposure write isn't separately metered — it's part of the project's overall ingestion volume.
- Same 500 req/min rate limit as ingestion endpoints. Use the 1000-row max per request to amortize.