Evaluation API
REST reference for server-side testing — authentication, flag config with ETags, evaluate, exposure and conversion, identities, errors and rate limits.
The evaluation API lets any backend read your feature flags, evaluate them for a user, and record exposures and conversions for server-side tests. The Node SDK wraps it; from any other language, call it directly.
Basics
Base URL: https://app.splittestpro.com
Paths: every route exists under both platform prefixes — /api/html/flags/… and /api/shopify/flags/…. Either prefix accepts any valid key: the key, not the path, decides the workspace. Use the one that matches your workspace’s platform.
Authentication: send a server API key in the Authorization header on every request:
Authorization: Bearer stp_<key id>_<secret>
A key in the query string (?api_key= or ?key=) is refused with 401 invalid_key before anything else runs. The API is for servers: it sends no CORS headers, so browsers can’t call it.
Bodies: JSON, at most 64 KB. A malformed body is 400 invalid_request.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/html/flags/config |
The workspace’s flags, with an ETag |
POST |
/api/html/flags/evaluate |
Flag values for one identity |
POST |
/api/html/flags/exposure |
Record that an identity experienced a variant |
POST |
/api/html/flags/conversion |
Record a conversion for an identity |
Identities
Every evaluate, exposure and conversion names a user with an identity: any stable, opaque id you choose.
- 1–128 characters of printable ASCII, no whitespace.
- No
@— an email address is refused. Use an internal user id, a random id stored against the user, or a hash. - In a hybrid test, use the pixel’s visitor id (
window.SplitTestPro.getVisitorId()) so your server and the pixel place the user in the same variant.
Anything else is 400 invalid_identity. You own the mapping from identity to person — see Privacy & Consent.
GET …/flags/config
Returns every flag in the workspace. Use it to cache flags and to notice changes cheaply.
curl https://app.splittestpro.com/api/html/flags/config \
-H "Authorization: Bearer $SPLITTESTPRO_API_KEY"
{
"config_version": "9f2c4e1a7b3d5c60",
"entitled": true,
"flags": [
{ "key": "new-checkout", "name": "New checkout", "state": "on", "rollout_pct": 25 },
{ "key": "free-shipping-banner", "name": "Free shipping banner", "state": "killed", "rollout_pct": 100 }
]
}
| Field | Meaning |
|---|---|
config_version |
16 hex characters. Changes whenever a flag is created, edited, killed, restored or deleted, and whenever an experiment that sets flag values starts, stops or changes. |
entitled |
false when the workspace has no paid plan. |
flags[].state |
on, off or killed. |
flags[].rollout_pct |
0–100. |
The response carries ETag: "<config_version>". Send it back as If-None-Match and an unchanged config answers 304 Not Modified with no body — the cheap way to poll:
curl -i https://app.splittestpro.com/api/html/flags/config \
-H "Authorization: Bearer $SPLITTESTPRO_API_KEY" \
-H 'If-None-Match: "9f2c4e1a7b3d5c60"'
POST …/flags/evaluate
Returns each requested flag’s value for one identity. Evaluate is a pure read: it records nothing and costs nothing against your tested sessions.
Request
| Field | Type | |
|---|---|---|
identity |
string | Required. See Identities. |
flags |
string[] | Optional. Up to 50 keys; omitted or empty evaluates every flag. |
assignments |
string | Optional. The pixel’s assignment string, expId:varId|expId:varId, for hybrid tests. Pairs that don’t belong to this workspace are ignored. |
curl https://app.splittestpro.com/api/html/flags/evaluate \
-H "Authorization: Bearer $SPLITTESTPRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"identity": "user_8231", "flags": ["new-checkout", "free-shipping-banner", "beta-search"]}'
Response (Cache-Control: no-store)
{
"config_version": "9f2c4e1a7b3d5c60",
"entitled": true,
"frozen": false,
"flags": {
"new-checkout": {
"value": true,
"reason": "experiment",
"experiment_id": "3f6c2a8e-5b1d-4c7a-9e2f-0a1b2c3d4e5f",
"variant_id": "b7e4d1c0-2a9f-4e8b-8c6d-5f4e3d2c1b0a"
},
"free-shipping-banner": { "value": false, "reason": "killed" },
"beta-search": { "value": false, "reason": "unknown" }
}
}
reason |
value |
Meaning |
|---|---|---|
killed |
false |
The flag is killed. |
experiment |
the variant’s value | A running experiment sets this flag; experiment_id and variant_id name the identity’s variant. A variant that doesn’t set the flag serves false. |
rollout |
true |
The flag is on and the identity is inside its rollout. |
off |
false |
The flag is off, or on with the identity outside its rollout. |
unknown |
false |
No flag has this key. |
unentitled |
false |
The workspace has no paid plan (entitled: false). Every flag answers this way. |
experiment_id and variant_id appear only with the experiment reason — they are what you send to the exposure call.
frozen is true while the workspace is over its monthly sessions limit (the enrollment freeze). No new identities enter experiments: an identity whose assignments names the experiment keeps that variant, and every other one gets the flag’s own state.
POST …/flags/exposure
Records that an identity experienced a variant of a server-side experiment. Call it at the moment the user sees the variant — when the page renders, the email sends, the price is quoted — not on every evaluate. An exposure is what enters a user into the results and counts toward tested sessions (once per distinct identity per month).
| Field | Type | |
|---|---|---|
identity |
string | Required. |
experiment_id |
string | Required. From the evaluate response. |
variant_id |
string | Required. From the evaluate response. |
event_id |
string | Required, at most 128 characters. Your idempotency key — a UUID you generate once per exposure and reuse on retries. |
curl https://app.splittestpro.com/api/html/flags/exposure \
-H "Authorization: Bearer $SPLITTESTPRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"identity": "user_8231", "experiment_id": "3f6c2a8e-5b1d-4c7a-9e2f-0a1b2c3d4e5f", "variant_id": "b7e4d1c0-2a9f-4e8b-8c6d-5f4e3d2c1b0a", "event_id": "0d9b8f3e-6a1c-4f2e-9d7b-1c2a3b4c5d6e"}'
{ "recorded": true }
| Answer | Meaning |
|---|---|
200 {"recorded": true} |
Recorded. |
200 {"recorded": false, "reason": "duplicate"} |
This event_id was already recorded — a retry or a replay. Nothing new was written. |
200 {"recorded": false, "reason": "frozen"} |
The workspace is over its monthly sessions limit; nothing was written. |
200 {"recorded": false, "reason": "not_running"} |
The experiment isn’t running (paused, completed or not started). |
409 pixel_owned |
The experiment runs On your site: the pixel records its exposures, and a server exposure would count the visitor twice. Don’t send exposures for hybrid tests. |
400 invalid_request |
A missing field, an event_id over 128 characters, or an experiment or variant that isn’t in this workspace. |
POST …/flags/conversion
Records a conversion for an identity — a purchase confirmed by your payment provider, a signup completed on the server.
| Field | Type | |
|---|---|---|
identity |
string | Required. |
event_id |
string | Required, at most 128 characters. An order id makes a natural idempotency key. |
event_type |
string | Optional, default conversion. The goal’s event key: lowercase letters, digits and _ (not page_view or page_viewed). Use the keys your conversion goals use. |
value |
number | Optional. A finite number, magnitude at most 1e15 — revenue for continuous metrics. |
currency |
string | Optional. Three-letter ISO 4217 code, e.g. USD. |
has_subscription |
boolean | Optional. |
assignments |
string | Optional. The pixel’s assignment string. When present, the conversion is attributed to the experiments it names (those in this workspace). When absent, the identity is attributed to every running server-side experiment it is enrolled in — except during the enrollment freeze, when nothing is derived. |
curl https://app.splittestpro.com/api/html/flags/conversion \
-H "Authorization: Bearer $SPLITTESTPRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"identity": "user_8231", "event_type": "purchase", "event_id": "order_10482", "value": 49.0, "currency": "USD"}'
| Answer | Meaning |
|---|---|
200 {"recorded": true} |
Recorded against each attributed experiment. |
200 {"recorded": false, "reason": "duplicate"} |
This event_id was already recorded. |
200 {"recorded": false, "reason": "unassigned"} |
The identity is in no running experiment (or none of the passed assignments belong to this workspace). |
400 invalid_request |
A missing event_id, a bad event_type, value or currency. Unlike the pixel, which drops bad fields silently, the API rejects the whole call. |
Server events carry device type Server in Results, no page URL (URL-based goals don’t fire from them) and no new-or-returning tag.
Errors
Every non-2xx answer is JSON with a machine-readable error and a sentence you can log:
{ "error": "invalid_identity", "message": "identity must not contain '@' — send an opaque id, never an email address." }
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
Malformed JSON, a missing or invalid field, more than 50 flags. |
| 400 | invalid_identity |
The identity breaks the identity rules. |
| 401 | invalid_key |
No key, a malformed, unknown or revoked key, or a key in the query string. |
| 409 | pixel_owned |
An exposure for an experiment served On your site. |
| 429 | rate_limited |
Over the key’s rate limit; retry after Retry-After seconds. |
| 500 | internal_error |
Something failed on our side; retry with backoff (same event_id). |
A workspace without a paid plan is not an error: config and evaluate answer 200 with entitled: false, and every flag is false with the reason unentitled.
Rate Limits
Limits are per key, per minute:
| Calls | Limit |
|---|---|
GET …/flags/config |
120 / minute |
POST …/flags/evaluate |
600 / minute |
POST …/flags/exposure + POST …/flags/conversion (shared) |
1,200 / minute |
Over the limit, the API answers 429 rate_limited with a Retry-After header in seconds. Requests with an invalid key are limited to 60 a minute per IP address. To stay well inside the evaluate limit, cache results: poll config with If-None-Match and re-evaluate only when config_version changes — what the Node SDK does.
Next Steps
- The Node client that does caching, retries and idempotency for you: Node SDK & Hybrid Tests
- How the values are decided: Server-Side Testing