Skip to main content
Raw HTTP is a first-class way to send events. The Python SDK is a convenience layer over exactly one endpoint — 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

POST {endpoint}/v1/events
Authorization: Bearer <api_key>
Content-Type: application/json
Content-Encoding: gzip        # optional
The Bearer key is the only tenancy input: your 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:
curl -X POST "https://ingest.yourcompany.com/v1/events" \
  -H "Authorization: Bearer $KEEPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sent_at": "2026-07-03T12:00:01.000Z",
    "events": [
      {
        "event_id": "0197a3f0-5e7a-7000-8000-3f6b2a91c001",
        "asset_id": "job_8412",
        "ts": "2026-07-03T12:00:00.123Z",
        "model": "fal-ai/flux-pro/v1.1",
        "provider": "fal",
        "status": "completed",
        "inputs": {"prompt": "a red bicycle on a beach"},
        "outputs": [{"kind": "image", "url": "https://cdn.example.com/out.png",
                     "content_type": "image/png", "width": 1024, "height": 1024}],
        "usage": {"n": 1, "duration_s": 6.2},
        "properties": {"tier": "pro", "generation_id": "req_abc123"}
      }
    ]
  }'
{"accepted": 1, "rejected": []}

The batch

The body is a JSON object with one required key and one recommended key:
{"events": [  ], "sent_at": "2026-07-03T12:00:00.000Z"}
events
array
required
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.
sent_at
string
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

{
  "schema_version": 1,
  "event_id":   "0197a3…",                  // REQUIRED — idempotency/dedupe key
  "asset_id":   "0197a3…",                  // REQUIRED — your correlation key
  "ts":         "2026-07-03T12:00:00.123Z", // REQUIRED — when the event happened
  "user_id":    "u_42",
  "model":      "fal-ai/flux-pro/v1.1",     // bare provider-native id
  "provider":   "fal",                      // lowercase vendor slug
  "status":     "completed",
  "inputs":     { "prompt": "…" },
  "outputs":    [ { "kind": "image", "url": "…" } ],
  "error":      { "message": "…", "type": "…", "code": "…", "http_status": 422 },
  "usage":      { "duration_s": 6.2, "n": 1 },
  "properties": { "tier": "pro", "session_id": "s_1" },
  "truncated":  ["inputs"]                  // fields you cut client-side, if any
}
fieldtyperequiredlimitrules
schema_versionintdefaults to 11–100out of range → bad_schema_version
event_idstring256 Bidentity rules below
asset_idstring256 Bidentity rules below
tsstringsee Timestamps
user_idstring256 Bidentity rules below
modelstring128 Bbare provider-native id; no control characters
providerstring128 Blowercased at ingest; no control characters
statusstring128 Bopen set; see Status
inputsobject128 KiBverbatim JSON object
outputsarray128 KiB / 100 refsalways an array of reference objects
errorobject32 KiBverbatim provider error
usageobject32 keysflat map of finite numbers; non-numeric entries are dropped per key
propertiesobject64 KiB, 128 keys; key ≤ 256 B, value ≤ 16 KiByour group-by tags
truncatedarraysubset of ["inputs","outputs","properties","error"]; unknown entries are dropped
A whole event that serializes to more than 256 KiB is rejected as 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_id is yours: every event carrying the same asset_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 makes GET /v1/assets/{id} unreachable.
  • event_id must be unique within (project, asset_id) and is the dedupe key: delivery is at-least-once, so resend freely on timeouts — a redelivered event_id must be byte-identical to the original. UUIDv7 is recommended because monotonic ids make equal-ts tie-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 (Z or ±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 ts rejects that event (bad_ts). The server never substitutes its own time: ts decides every fold, and a fabricated timestamp would decide winners silently.
  • Floor: ts before 2020-01-01 rejects (bad_ts) — epoch-zero garbage would permanently win the “earliest” aggregates.
  • Future clamp: ts more than 5 minutes past the server clock is clamped to that ceiling; the original string is retained server-side. One ts=2050 event must not permanently win every “latest” fold for its asset.
  • Skew correction: when the batch carries sent_at, every ts is shifted by received_at − sent_at, quantized to whole seconds and only beyond ±5 s (see The batch). Omit sent_at when 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 spelling canceled is normalized to cancelled at 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 set completed_at).
What you can rely on when events collide or arrive out of order:
  • Every field folds latest-non-empty-wins, ordered by (ts, event_id) — bytewise event_id order breaks equal-ts ties 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 an asset_id. A retry is a new asset, linked to its predecessor via the reserved reroll_of property (and numbered with attempt).
  • A terminal completed clears the folded error, so a fail-then-succeed asset never shows a stale error. A properties-only annotation on a failed asset does not clear its error.
  • properties fold per key: each event updates only the keys it carries.
  • The asset’s anchor timestamps: submitted_at is the earliest status="submitted" event, completed_at the latest terminal event, and first_seen_at the earliest event of any kind — set even for one-shot captures that never sent submitted.

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/false lowercase, numbers as JSON renders them, containers as compact JSON ({"x":1}), never language reprs like True or {'x': 1}. Note 1 and 1.0 are different stored values — send strings if you need cross-type equality. For flag-like tags, send true/false or "1".
  • inputs / outputs / error / usage are 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.prompt is 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 a url unless 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:
keymeaning
session_idthe user session / workflow run this asset belongs to
generation_idthe provider’s request/call id — the invoice-reconciliation join key; required when you record one provider call as N assets
reroll_ofasset_id of the predecessor attempt — the retry edge
attempt1-based attempt number in a retry chain
output_index0-based position within a fan-out group
environmentper-event override of the key’s environment: dev | staging | prod (any other value falls back to the key’s environment)
source_asset_idasset_id of the upstream asset this generation consumed — e.g. the picked image a video was animated from. The cross-modality funnel edge
Reserved namespaces:
  • group: prefix — contests. The key group:<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, then picked when 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-sent keeps:* 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.
Unknown top-level fields are accepted and retained server-side (write-only forensic storage, not queryable), so sending fields from a newer contract version never loses data. The exceptions are cost and project_id, which are stripped entirely.

Responses

202 — per-event outcomes

Every structurally-valid request returns 202 with per-event results:
{"accepted": 2, "rejected": [{"index": 1, "reason": "bad_ts"}]}
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:
reasoncause
not_an_objectthe array element isn’t a JSON object
missing_event_idrequired field absent
missing_asset_idrequired field absent
missing_tsrequired field absent
bad_tsunparseable timestamp, or ts before 2020-01-01
bad_schema_versionnot an integer in 1–100
bad_field:<name>that field violates its cap or charset (e.g. bad_field:model)
event_too_largethe whole event serializes over 256 KiB
internala server-side fault isolated to this event — the one code where a retry may succeed

Error statuses

statusmeaningretry?
400malformed JSON body, bad gzip, or events missing / not an arrayno — fix the request
401missing/invalid/revoked/expired API keyno — fix the key
413batch over 500 events or 5 MiB (before or after gunzip)split the batch, then yes
503storage temporarily unavailableyes — 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():
const ENDPOINT = "https://ingest.yourcompany.com/v1/events";

async function send(events) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KEEPS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ events, sent_at: new Date().toISOString() }),
  });
  if (res.status === 503) throw new Error("transient — retry with backoff");
  if (!res.ok) throw new Error(`ingest rejected the request: ${res.status}`);

  const body = await res.json(); // { accepted, rejected: [{index, reason}] }
  for (const r of body.rejected) {
    // Terminal — retrying the same bytes cannot succeed. Log and fix.
    console.warn(`keeps: event ${r.index} rejected: ${r.reason}`);
  }
  return body;
}

// One generation = one asset_id, minted by YOU and reused on every event.
const assetId = crypto.randomUUID();

// 1. Submission.
await send([{
  event_id: crypto.randomUUID(),
  asset_id: assetId,
  ts: new Date().toISOString(),
  model: "fal-ai/flux-pro/v1.1",
  provider: "fal",
  status: "submitted",
  inputs: { prompt: "a red bicycle on a beach" },
  user_id: "u_42",
}]);

// … your provider call runs, untouched …

// 2. Completion on the SAME asset_id — plus the provider's request id,
//    tagged as the reserved `generation_id` property.
await send([{
  event_id: crypto.randomUUID(),
  asset_id: assetId,
  ts: new Date().toISOString(),
  status: "completed",
  outputs: [{ url: "https://cdn.example.com/out.png", width: 1024, height: 1024 }],
  usage: { n: 1, duration_s: 6.2 },
  properties: { generation_id: providerRequestId, tier: "pro" },
}]);
Batch as aggressively as you like (up to 500 events / 5 MiB per request), and resend on timeouts without fear — event_id deduplication makes redelivery safe.

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.