> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keeps.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# HTTP ingestion API

> Send events as raw JSON over HTTPS — the full POST /v1/events contract for Node, Go, or any stack without the Python SDK.

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:

```bash theme={null}
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"}
      }
    ]
  }'
```

```json theme={null}
{"accepted": 1, "rejected": []}
```

## The batch

The body is a JSON object with one required key and one recommended key:

```json theme={null}
{"events": [ … ], "sent_at": "2026-07-03T12:00:00.000Z"}
```

<ParamField path="events" type="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.
</ParamField>

<ParamField path="sent_at" type="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).
</ParamField>

## The event object

```jsonc theme={null}
{
  "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
}
```

| 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](#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](#status-and-what-the-fold-promises)                         |
| `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 |

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](#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:

| 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 |

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:

```json theme={null}
{"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:

| 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()`:

```javascript theme={null}
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.

## Related

<CardGroup cols={2}>
  <Card title="Core concepts" href="/core-concepts" icon="diagram-project">
    Assets, events, correlation by `asset_id`, and how the fold works.
  </Card>

  <Card title="API reference" href="/api-reference" icon="code">
    The Python SDK surface — the convenience layer over this endpoint.
  </Card>

  <Card title="Privacy & safety" href="/privacy-and-safety" icon="shield-halved">
    Verbatim capture, references-not-bytes, server-derived tenancy.
  </Card>

  <Card title="Reliability" href="/reliability" icon="bolt">
    How the Python SDK handles these responses for you.
  </Card>
</CardGroup>
