Ingest events
POST /v1/events records custom experimentation events attributed to a variable and variation arm. Use this to capture metrics (purchases, clicks, conversions, numerical values) against an experiment for the results charts.
Request
POST /v1/events HTTP/1.1
Host: api.redpennon.dev
X-API-Key: 00000000-0000-0000-0000-000000000000
Content-Type: application/json
{
"events": [
{
"event": "purchase",
"variable": "checkout-flow",
"variation": "variant-a",
"user": { "id": "user-123" },
"value": 49.99,
"occurred_at": "2026-05-19T02:30:00Z",
"evaluation_trace": "rpe_v1:..."
}
]
}
Body
| Field | Type | Required | Description |
|---|---|---|---|
events | object[] | yes | Array of event objects (see fields below). May be empty — returns 202 {"accepted": 0}. |
Event object fields
| Field | Type | Required | Description |
|---|---|---|---|
event | string | yes | Event name (e.g. "purchase", "button_clicked"). |
variable | string | yes | Variable key — must resolve to a variable in the API key's project. |
variation | string | yes | Variation slug — must exist on the variable's parent feature. |
user | object | no | User context. Same shape as the evaluation user object. Omit or send {} for anonymous. |
value | number | no | Numerical measurement attached to the event (e.g. revenue, duration). |
occurred_at | string | no | ISO-8601 timestamp of when the event occurred. Defaults to ingest time. |
event_id | string | no | Client-supplied de-duplication key, at most 200 characters. Unique per environment: an event whose event_id has already been stored is counted as a duplicate and not stored again. Omit it and replays are stored twice, which is the right behaviour for genuinely repeated events. |
evaluation_trace | string | no | Token from a prior POST /v1/variables/{key} response. When supplied and valid, the stored event is marked evaluation_trace_verified = true, confirming the event came from a real, recently-verified evaluation. |
Safe retries
Network retries are the normal case, not the exception: a client that
times out mid-request has no way to know whether the batch landed. Send
an event_id on each row and a retry becomes free — already-stored keys
are skipped rather than double-counted.
{
"events": [
{"event": "purchase", "variable": "checkout-v2", "variation": "on",
"user": {"id": "user-123"}, "value": 42.5, "event_id": "order-9f31c8"}
]
}
The response reports what was actually recorded:
{"accepted": 0, "duplicates": 1}
duplicates is present only when at least one row was skipped. The key
is scoped to the environment, so the same id may be reused in staging
and production. Repeats within a single batch collapse too.
Without an event_id there is no way to tell a retry from two genuine
events — two purchases by the same user in the same second are a
legitimate pair — so events with no key are always stored.
To get evaluation_trace_verified = true on an event:
- Call
POST /v1/variables/{key}with a stable user (idoremail). - The response includes
evaluation_tracewhen a variation was served. - Pass that string as
evaluation_tracein the corresponding event row.
The token is environment- and variation-scoped with a short TTL (default 5 minutes), so stale or replayed tokens are rejected silently — the event is still accepted, just not verified.
Response
202 Accepted
{ "accepted": 3 }
accepted is the number of events written. Validation errors on any event in the batch cause the entire request to be rejected before writes begin (400).
Errors
Validation runs before governance. The first invalid event short-circuits the entire batch.
| Status | Body | When |
|---|---|---|
400 | {"error": "\"events\" field is required."} | Body has no events key. |
400 | {"error": "\"events\" must be a list."} | events is not an array. |
400 | {"error": "events[N] must be an object."} | An entry is not a JSON object. |
400 | {"error": "events[N].event is required."} | event field is blank or missing. |
400 | {"error": "events[N].variable is required."} | variable field is blank or missing. |
400 | {"error": "events[N].variation is required."} | variation field is blank or missing. |
400 | {"error": "events[N].variable resolves to unknown variable."} | variable key doesn't resolve in this project. Unlike evaluation, unknown variables are not fail-open here. |
400 | {"error": "events[N].variation is not defined on feature."} | variation slug doesn't exist on the variable's parent feature. |
400 | {"error": "events[N].value must be numeric when set."} | value was sent as a non-numeric type. |
400 | {"error": "events[N].occurred_at must be ISO-8601 when set."} | occurred_at could not be parsed. |
401 | {"error": "Invalid or missing API key."} | Missing or invalid X-API-Key. |
403 / 429 | {"error": "...", "code": "..."} | Governance (see Errors). Governance runs after per-event validation. |
Example: cURL
export REDPENNON_API_KEY=00000000-0000-0000-0000-000000000000
curl -X POST "https://api.redpennon.dev/v1/events" \
-H "X-API-Key: $REDPENNON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"event": "purchase",
"variable": "checkout-flow",
"variation": "variant-a",
"user": { "id": "user-123" },
"value": 49.99
}
]
}'