POST /v1/events — and everything the SDK
does, you can do with a plain HTTPS request from any language. This page is the
complete contract for that endpoint: what you may put on the wire and what the
backend promises to do with it.
The contract only widens over time: a payload accepted today is accepted
forever. Caps only rise, vocabularies only grow, required fields only become
optional.
Endpoint and authentication
project_id is derived
server-side from it and there is no way to send one in the payload (a
client-sent project_id is stripped). A missing, malformed, revoked, or
expired key returns 401 for the whole request.
Bodies may optionally be gzipped (Content-Encoding: gzip). A body that fails
to gunzip returns 400.
Send your first event
One request, one event, one complete generation:The batch
The body is a JSON object with one required key and one recommended key:Up to 500 event objects per request. The body may be at most 5 MiB —
both as received and after gzip decompression (re-checked post-gunzip).
Over either cap the whole request returns
413 and nothing is ingested;
split the batch and retry.Your wall clock at POST time, same format as
ts. The server uses it to
measure and cancel your clock skew: it computes received_at − sent_at and
shifts every ts in the batch by that offset, preserving relative event
spacing. The applied correction is quantized to whole seconds and only
applied beyond ±5 s — so honest clocks are never shifted by network latency,
and redelivering the same batch (each attempt regenerates sent_at) stores
identical timestamps. A sent_at implying more than ±7 days of skew is
ignored — a broken clock never shifts a whole batch.Backfills of historical data must omit sent_at — otherwise your
intentionally-past timestamps would be “corrected” to now. Without it,
past ts values pass through unshifted (the floor and clamp below still
apply).The event object
| field | type | required | limit | rules |
|---|---|---|---|---|
schema_version | int | defaults to 1 | 1–100 | out of range → bad_schema_version |
event_id | string | ✓ | 256 B | identity rules below |
asset_id | string | ✓ | 256 B | identity rules below |
ts | string | ✓ | — | see Timestamps |
user_id | string | 256 B | identity rules below | |
model | string | 128 B | bare provider-native id; no control characters | |
provider | string | 128 B | lowercased at ingest; no control characters | |
status | string | 128 B | open set; see Status | |
inputs | object | 128 KiB | verbatim JSON object | |
outputs | array | 128 KiB / 100 refs | always an array of reference objects | |
error | object | 32 KiB | verbatim provider error | |
usage | object | 32 keys | flat map of finite numbers; non-numeric entries are dropped per key | |
properties | object | 64 KiB, 128 keys; key ≤ 256 B, value ≤ 16 KiB | your group-by tags | |
truncated | array | — | subset of ["inputs","outputs","properties","error"]; unknown entries are dropped |
event_too_large. A single field over its cap (or an identity/short field
containing control characters) rejects that event as bad_field:<name> —
never the batch.
Identity fields
event_id, asset_id, and user_id are opaque strings up to 256 bytes with
no control characters. Identity is byte-equality — no case folding, no
Unicode normalization.
asset_idis yours: every event carrying the sameasset_id(within your project) folds into one asset. Derive it from a stable internal key (your job id, your row id). Stick to[A-Za-z0-9._:-]— a/in the id makesGET /v1/assets/{id}unreachable.event_idmust be unique within(project, asset_id)and is the dedupe key: delivery is at-least-once, so resend freely on timeouts — a redeliveredevent_idmust be byte-identical to the original. UUIDv7 is recommended because monotonic ids make equal-tstie-breaks follow emit order.
Fields the server stamps (never wire inputs)
project_id (from the API key), environment (the key’s environment slug —
override per event with the reserved environment property), origin
(sdk for everything arriving through this endpoint — a client-sent origin
is overwritten), and received_at. A client-sent cost is dropped
unconditionally: cost is computed server-side from pricing tables and appears
nowhere, not even in forensic retention.
Timestamps
- Format: RFC 3339 with millisecond precision and an explicit offset
(
Zor±hh:mm):2026-07-03T12:00:00.123Z. A naive timestamp (no offset) is accepted and interpreted as UTC — never the server’s local zone. - Unparseable
tsrejects that event (bad_ts). The server never substitutes its own time:tsdecides every fold, and a fabricated timestamp would decide winners silently. - Floor:
tsbefore 2020-01-01 rejects (bad_ts) — epoch-zero garbage would permanently win the “earliest” aggregates. - Future clamp:
tsmore than 5 minutes past the server clock is clamped to that ceiling; the original string is retained server-side. Onets=2050event must not permanently win every “latest” fold for its asset. - Skew correction: when the batch carries
sent_at, everytsis shifted byreceived_at − sent_at, quantized to whole seconds and only beyond ±5 s (see The batch). Omitsent_atwhen backfilling.
Status and what the fold promises
status is an open set — any string up to 128 bytes — with a reserved
vocabulary:
- Terminal (closed set):
completed·failed·cancelled. The spellingcanceledis normalized tocancelledat ingest. - Non-terminal:
submitted,queued,processing, and every string the server doesn’t recognize. Unknown statuses fold as the asset’s current status but never mark it finished (they never setcompleted_at).
- Every field folds latest-non-empty-wins, ordered by
(ts, event_id)— bytewiseevent_idorder breaks equal-tsties deterministically. - Status is terminal-sticky. Once an asset has a terminal status, no
non-terminal event can flip it back to in-flight, regardless of arrival
order or
ts. Among terminal events, the latest(ts, event_id)wins. - Consequence: one attempt = one
asset_id. Never resubmit anasset_id. A retry is a new asset, linked to its predecessor via the reservedreroll_ofproperty (and numbered withattempt). - A terminal
completedclears the folded error, so a fail-then-succeed asset never shows a stale error. A properties-only annotation on afailedasset does not clear its error. propertiesfold per key: each event updates only the keys it carries.- The asset’s anchor timestamps:
submitted_atis the earlieststatus="submitted"event,completed_atthe latest terminal event, andfirst_seen_atthe earliest event of any kind — set even for one-shot captures that never sentsubmitted.
Empty means absent
One equivalence class means “not set”: an omitted field,null, "", {},
and [] (including whitespace-only and "null" serialized forms) are all
normalized to unset. An event that doesn’t carry a field can never blank out a
value a previous event set — you can always send sparse events safely.
The one deliberate exception: a property key with a null value is a
tombstone. It folds per-key like any value and reads back as the empty
string — that is how you clear a tag. Scalar fields can’t be cleared; send a
new value instead.
Value encodings
- Property values are stored as strings with one canonical encoding.
String values pass through verbatim; every non-string value is JSON-encoded:
true/falselowercase, numbers as JSON renders them, containers as compact JSON ({"x":1}), never language reprs likeTrueor{'x': 1}. Note1and1.0are different stored values — send strings if you need cross-type equality. For flag-like tags, sendtrue/falseor"1". inputs/outputs/error/usageare stored as canonical JSON — the server re-encodes them compactly, so the stored form is always exactly-once-encoded JSON. If you send a top-level JSON string for one of these fields, it is parsed and re-dumped rather than stored verbatim; a string that doesn’t parse is stored as a JSON-encoded string scalar.
Field shapes
inputs— a JSON object, captured verbatim. There is no server-side redaction: hash or strip sensitive values before you send them.inputs.promptis the reserved key the dashboard reads for the prompt.outputs— always a JSON array of reference objects; a bare object is wrapped into a one-element array, never rejected. References, not bytes: each ref needs aurlunless it carries"inline": true. Reserved optional ref keys:kind,content_type,width,height,duration_s,size_bytes,seed.error— a JSON object carrying the provider’s error verbatim. Conventional keys (never enforced):message(expected),type,code,http_status,provider_raw.usage— a flat map of finite numbers: the provider-reported billing meters, verbatim. They exist only in the provider’s response at call time — capture them or lose them. Reserved key names:n,duration_s,tokens_in,tokens_out,compute_seconds,characters,megapixels,width,height,audio(0/1). Non-numeric values are dropped per key.
model and provider
model is the bare provider-native id (fal-ai/flux-pro/v1.1,
gpt-image-2) — never prefix the vendor into it. provider carries the
vendor separately as a lowercase slug (fal, replicate, openai); the same
model exists on multiple providers at different prices, so pricing keys on the
pair. provider is a dimension only — correlation is always asset_id.
Reserved names
These property keys are ordinary tags with contract semantics attached — use them for these meanings and nothing else:| key | meaning |
|---|---|
session_id | the user session / workflow run this asset belongs to |
generation_id | the provider’s request/call id — the invoice-reconciliation join key; required when you record one provider call as N assets |
reroll_of | asset_id of the predecessor attempt — the retry edge |
attempt | 1-based attempt number in a retry chain |
output_index | 0-based position within a fan-out group |
environment | per-event override of the key’s environment: dev | staging | prod (any other value falls back to the key’s environment) |
source_asset_id | asset_id of the upstream asset this generation consumed — e.g. the picked image a video was animated from. The cross-modality funnel edge |
group:prefix — contests. The keygroup:<your_group_id>marks the asset as a member of that contest (a pick-of-N batch, a shot’s candidates, a head-to-head); the value is the asset’s verdict in that contest:candidate, thenpickedwhen chosen (or your own vocabulary — ranks like"1",rejected, …). Because properties fold per key, one asset can sit in many contests with independent verdicts, a contest can have zero or many winners, and membership can be asserted any time later by an annotation event. Group ids and values are yours; keeps never rewrites this family.keeps:prefix — server-derived properties. Client-sentkeeps:*keys are dropped at ingest, so a platform-derived tag can never be clobbered by (or clobber) one of yours.__*__(dunder-wrapped) — SDK sentinel markers (__bytes__,__truncated__,__dropped__,__unserializable__). Never use them as your own keys.error_kind— reserved for the server-side error taxonomy; client-supplied values are dropped at ingest.
cost and project_id, which
are stripped entirely.
Responses
202 — per-event outcomes
Every structurally-valid request returns202 with per-event results:
accepted + rejected.length always equals the number of events you sent.
Accepted events are written to storage before the 202 returns — it is an
acknowledgement, not a queue receipt. One bad event never poisons the batch:
the rest are ingested normally, and a batch where every event rejects still
returns 202 (with accepted: 0).
Rejects are terminal: retrying identical bytes cannot succeed, so log the
reason and fix the producer. The reason codes are stable identifiers:
| reason | cause |
|---|---|
not_an_object | the array element isn’t a JSON object |
missing_event_id | required field absent |
missing_asset_id | required field absent |
missing_ts | required field absent |
bad_ts | unparseable timestamp, or ts before 2020-01-01 |
bad_schema_version | not an integer in 1–100 |
bad_field:<name> | that field violates its cap or charset (e.g. bad_field:model) |
event_too_large | the whole event serializes over 256 KiB |
internal | a server-side fault isolated to this event — the one code where a retry may succeed |
Error statuses
| status | meaning | retry? |
|---|---|---|
400 | malformed JSON body, bad gzip, or events missing / not an array | no — fix the request |
401 | missing/invalid/revoked/expired API key | no — fix the key |
413 | batch over 500 events or 5 MiB (before or after gunzip) | split the batch, then yes |
503 | storage temporarily unavailable | yes — with backoff |
503 is the only transient signal. Ingest never returns a 5xx because of
your data: any per-event fault becomes a per-event reject in the 202 body,
and an unrecognized storage error classifies as 503 so a misclassification
fails toward retry, never toward data loss.
A complete Node integration
No SDK, no dependencies —fetch and crypto.randomUUID():
event_id deduplication makes redelivery
safe.
Related
Core concepts
Assets, events, correlation by
asset_id, and how the fold works.API reference
The Python SDK surface — the convenience layer over this endpoint.
Privacy & safety
Verbatim capture, references-not-bytes, server-derived tenancy.
Reliability
How the Python SDK handles these responses for you.