<!--
  The ingest wire contract. Published at https://vinktar.com/api.md

  Two rules for editing this file:

  1. Check every claim against the code before writing it here. Numbers trace to constants
     (IngestBatch::MaxSize, ErrorBatch::MaxSize, EventValidator::Max*, ErrorValidator::Max*,
     RequestTransformerListener::MAX_*) and to spec/limits.json, which is generated from those
     constants by spec/bin/generate-limits.php. Change the constant first and let this follow.
  2. ASCII only. This file is served as a static asset and has been read by clients that assumed
     Latin-1, which turned every em dash into mojibake. No em dashes, no smart quotes, no arrows,
     no math symbols. Plain hyphens, straight quotes, "->" for arrows, "<=" for limits.
-->

# api.md

The ingest wire contract that Vinktar SDKs are written against.

This is a published contract. Field names, error codes and response keys in this document are
promises: renaming one breaks every SDK in the field, so they change by addition, never in place.

**Who this is for.** Anyone writing an SDK, an integration, or a script that sends data to
Vinktar. It is deliberately complete rather than friendly: an implementer should be able to build
a correct client from this file alone, without reading server source and without guessing. Where
behaviour is surprising, the surprise is written down. Where something does not exist, that is
written down too, in section 13.

**Related documents.**

- `spec/PROTOCOL.md` in the repository: what an SDK must *do* (batching, retry, buffering,
  offline behaviour). This file is the wire; that file is the client.
- `spec/limits.json`: every numeric limit below, generated from the server constants, so a
  generated SDK can compile them in rather than transcribing them.

---

## 1. Transport

| | |
|---|---|
| Ingest host | `https://in.vinktar.com` |
| Content type | `application/json` on every endpoint except `/v1/sourcemaps`, which is `multipart/form-data` |
| Methods | `POST`, except `GET /v1/pixel.gif` and `GET /v1/health` |
| Compression | `Content-Encoding: gzip` accepted on every endpoint except `/v1/sourcemaps` |
| TLS | Required. There is no plaintext ingest endpoint |

### 1.1 Request size ceilings

Enforced before the body is parsed, so an oversized request costs nothing and returns a clean
`413` rather than dying inside PHP's `post_max_size`.

| Path | Ceiling |
|---|---|
| `/v1/*` and `/api/{id}/envelope/`, `/api/{id}/store/` | 5 MiB (5,242,880 bytes) |
| `/v1/sourcemaps` | 60 MiB (62,914,560 bytes) |
| Everything else (the session-authenticated GraphQL API) | 10 MiB |

Over the ceiling is `413 {"error": "payload_too_large", "max_bytes": <limit>}`. Split and resend;
retrying the same body fails identically.

Three details worth writing a client against:

- The declared `Content-Length` is checked first, then the actual body length, so a chunked
  request or a lying header is caught either way.
- The ceiling applies to **compressed** bytes. A gzip body that would inflate past the ceiling is
  rejected during inflation, without ever being fully decoded. Malformed gzip is `400`, not `413`.
- Source map uploads are multipart and are never inflated. A gzipped body there is read as raw
  bytes and fails. The Sentry envelope path inflates for itself, accepts `deflate` as well as
  `gzip`, and applies its own 10 MiB **decoded** ceiling. Any other encoding on that path is
  `400 unsupported_content_encoding`.

### 1.2 CORS

Browser SDKs call these endpoints cross-origin, so the policy is part of the contract.

| | `/v1/*` | `/api/{id}/envelope/`, `/api/{id}/store/` |
|---|---|---|
| `Access-Control-Allow-Origin` | `*` | `*` |
| Methods | `GET`, `POST`, `OPTIONS` | `POST`, `OPTIONS` |
| Request headers allowed | `Content-Type`, `Content-Encoding`, `X-Vinktar-Key` | `Content-Type`, `Content-Encoding`, `X-Sentry-Auth` |
| Preflight cache | 24 hours | 24 hours |

**Send no header outside that list.** A custom header of your own (a client version, a request id)
turns a simple request into a preflight that fails, and every send then looks like a network error
even though nothing reached the server. Put client metadata in the body instead: `context` on
events, `client_report` on the batch.

Credentials are never used. Authentication is by key, not cookie, and the endpoints hold no
session, which is why `*` is safe here.

`Retry-After` is always exposed to browser clients, and `X-RateLimit-Categories` is exposed
whenever it is present.

---

## 2. Authentication

Two headers exist and must never be confused.

| Header | Carries | Used by |
|---|---|---|
| `X-Vinktar-Key` | project write key, prefix `vnk_pk_` | SDKs, every `/v1/*` route |
| `X-Vinktar-ApiKey` | app session JWT | the admin app and GraphQL only, never an SDK |

The write key may also travel as `?_k=<key>` in the query string. That exists for the tracking
pixel and for `navigator.sendBeacon`, where headers cannot be set. Prefer the header everywhere
else: query strings end up in access logs and referrer headers.

### 2.1 Scopes

Keys carry a comma-separated scope list.

| Scope | Grants |
|---|---|
| `write` | `/v1/batch`, `/v1/identify`, `/v1/errors`, `/v1/pixel.gif`, the Sentry routes |
| `cli` | `/v1/sourcemaps` |

A valid key without the required scope is `403 {"error": "<scope>_scope_required"}`, deliberately
not `401`, so a client can tell "wrong key" from "right key, wrong permission" without guessing.

### 2.2 Authentication failures

| Status | Code | Means |
|---|---|---|
| `401` | `missing_api_key` | No header and no `_k` |
| `401` | `invalid_api_key` | Unknown, malformed or revoked key |
| `401` | `project_mismatch` | Sentry DSN whose project id is not this key's project |
| `403` | `write_scope_required`, `cli_scope_required` | Valid key, wrong scope |
| `429` | `auth_rate_limited` | Per-IP limit, applied *before* the key is read |

`auth_rate_limited` can arrive for a request that never carried a valid key, on both `/v1/*` and
the Sentry paths. Treat it as a transport-level backoff, not as a credential problem.

---

## 3. The response contract

### 3.1 Status codes

| Status | Where | Meaning |
|---|---|---|
| `200` | `/v1/pixel.gif`, `/v1/health` | The pixel always returns the GIF; health returns its checks |
| `201` | `/v1/sourcemaps` | Artifacts stored |
| `202` | `/v1/batch`, `/v1/identify`, `/v1/errors`, Sentry routes | Durably queued |
| `400` | any | Malformed request. The code names the problem |
| `401`, `403` | any | See section 2.2 |
| `413` | any | Too many items or too many bytes. Split and resend |
| `429` | any | Rate limited or capped. See section 4 |
| `503` | ingest routes | Storage degraded. Retryable, batch not accepted |

### 3.2 What 202 means

**A `202` means the data is durably queued in ClickHouse, not buffered in memory.** The write path
waits for the async-insert flush before answering. A client may free its buffer on `202` and does
not need its own durability story.

The corollary matters just as much: `503 {"error": "storage_unavailable"}` means nothing was
stored. It carries `Retry-After: 10`. Keep the batch and send it again. A client that treats `503`
as success loses data silently, which is the worst failure mode available here.

### 3.3 Error envelope

Every non-2xx response is a JSON object with an `error` key holding a stable machine code, plus
whatever context that code carries.

```json
{ "error": "payload_too_large", "max_bytes": 5242880 }
{ "error": "batch_too_large", "max": 1000 }
{ "error": "quota_exceeded", "quota_bytes": 524288000, "used_bytes": 523900000 }
```

Branch on `error`. Never parse the human-readable part of anything, and never branch on the status
code alone: `429` has four distinct causes with opposite correct responses.

---

## 4. Rate limits and retry

### 4.1 The four 429 codes

| Code | Scope | `Retry-After` | Correct response |
|---|---|---|---|
| `rate_limited` | Per-project plan bucket, or the global shed | Seconds | Back off, retry |
| `auth_rate_limited` | Per-IP, before key extraction | Seconds | Back off, retry |
| `monthly_cap_exceeded` | The project's monthly event ceiling | The first day of next month, UTC | Stop. Surface a billing or config error |
| `monthly_error_cap_exceeded` | The project's monthly error ceiling | The first day of next month, UTC | Stop. Surface a billing or config error |

A retry loop that sleeps for `Retry-After` without reading the code will sleep for weeks on either
monthly cap. That is the single most expensive mistake available in this document.

### 4.2 Category backoff

`429` responses may carry `X-RateLimit-Categories: <seconds>:<cat>;<cat>`, where each category is
one of `event`, `identify`, `error`. Hold only the listed categories and keep sending the others.
The header is omitted entirely when the limit is not per-category, for example per-IP auth limiting,
because an empty list is a malformed header that clients have to parse around.

### 4.3 The retry algorithm an SDK should implement

1. On `202`, drop the batch from the buffer. Read `rejected` and `errors` for debug logging.
2. On `413`, split the batch and resend. Do not retry it unchanged.
3. On `429`, read `error` first. If it is a monthly cap, stop and surface it. Otherwise sleep for
   `Retry-After`, holding only the categories in `X-RateLimit-Categories`.
4. On `503`, sleep for `Retry-After` (10 seconds) and retry the same batch.
5. On `400`, `401` or `403`, do not retry. The request is wrong, not unlucky.
6. On a transport error, retry with exponential backoff and jitter. Supply a stable `event_id` per
   event so a duplicate delivery de-duplicates instead of double-counting.

Rate limiting here is fairness, not correctness. A client that buffers and retries makes brief
throttling invisible to the person using it.

---

## 5. POST /v1/batch

The main event endpoint. Analytics events, identity links and trait writes in one request.

```http
POST /v1/batch HTTP/1.1
Host: in.vinktar.com
Content-Type: application/json
X-Vinktar-Key: vnk_pk_...
```

```json
{
  "batch": [
    {
      "name": "order_placed",
      "event_id": "0f2a2f9c-1f2b-4c0a-9a1e-1d6b1f6c9a11",
      "timestamp": "2026-08-19T22:51:04.220Z",
      "device_id": "d_9f2c",
      "user_id": "u_8aa1",
      "session_id": "s_41ba",
      "payload": { "total_cents": 14820, "currency": "EUR", "items": 3 },
      "context": { "page_url": "https://acme.com/checkout" }
    }
  ],
  "identify": [
    { "device_id": "d_9f2c", "user_id": "u_8aa1", "$set": { "$email": "alex@acme.com" } }
  ],
  "context": { "lib": "vinktar-js", "lib_version": "1.4.0" },
  "client_report": { "dropped_events": 4, "reason": "buffer_full" }
}
```

**Cap: 1,000 items per request**, counting `batch` entries plus `identify` entries together. Over
that is `413 {"error": "batch_too_large", "max": 1000}`.

### 5.1 The event object

| Field | Type | Notes |
|---|---|---|
| `name` | string, required | The event name. Empty or non-string is rejected |
| `event_id` | string, optional | Your idempotency key. See 5.5 |
| `timestamp` | string or number, optional | ISO-8601, epoch seconds, or epoch milliseconds. Defaults to arrival time |
| `device_id` | string, optional | The anonymous id. Send it on every event you can |
| `user_id` | string, optional | The signed-in id. Sending both on one event also records the link |
| `session_id` | string, optional | Free-form session grouping |
| `payload` | object, optional | Your properties. Note the name: `payload`, not `properties` |
| `context` | object, optional | Per-event context, merged over the batch-level `context` |

Tenancy is never read from the body. Project and workspace come from the resolved key, so there is
no field that can point an event at somebody else's data.

### 5.2 Context becomes columns

Keys in `context` (per event, or shared at the batch level) that match a known name are stored as
typed columns rather than as free properties, which is what makes them filterable everywhere in the
product. Both the bare and the `$`-prefixed spelling are accepted, so an SDK ported from another
vendor's naming does not have to rewrite them.

Recognised names include `os`, `os_version`, `browser`, `browser_version`, `device` /
`device_type`, `page_url` / `url` / `$current_url`, `referrer`, `referring_domain`,
`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `country` /
`mp_country_code`, `region`, `city`, `lib` / `$lib`, `lib_version`, `release`, `environment`.

The full generated table is `spec/context-map.json`. Anything not in it stays an ordinary property,
kept in the event's JSON and reachable from the query editor; that is where the SDKs' own
`page_title`, `$runtime`, `$runtime_version` and `$server_name` live today. They are not columns,
so they are not filters.

Three of those are filled in server-side when you omit them: device type, OS and browser are parsed
from `User-Agent`, and country, region and city come from edge headers only (`cf-ipcountry`,
`x-geo-country`, `x-geo-region`, `x-geo-city`). There is no IP geolocation database in the request
path. The client IP itself is HMAC-hashed with a server secret and stored as a 32-character digest;
the raw address is never written.

### 5.3 Per-event limits

Validation is tolerant. A bad event is counted in `rejected` and named in `errors`; it never fails
the batch.

- 255 properties per event, counting `payload` and `context` together
- 255 bytes per string value. Longer values are rejected, not truncated
- 3 levels of nesting
- `timestamp` within `-7 days` and `+1 hour` of now

### 5.4 Response

```json
{
  "received": 12,
  "rejected": 1,
  "errors": [{ "index": 4, "code": "missing_name" }],
  "traits_dropped": [{ "user_id": "u_8aa1", "key": "plan", "code": "value_too_large" }],
  "identify_ignored": [{ "device_id": "d_9f2c", "user_id": "u_8aa1", "code": "no_op" }]
}
```

| Key | Meaning |
|---|---|
| `received` | Accepted events plus every identity pair submitted, including pairs reported in `identify_ignored` |
| `rejected` | Events that failed validation |
| `errors` | One entry per rejected event: its `index` in your `batch` array and a `code` |
| `traits_dropped` | Present only when non-empty. A trait that did not survive its limits |
| `identify_ignored` | Present only when non-empty. An identify entry that stored nothing |

Validation codes in `errors`: `not_object`, `missing_name`, `invalid_timestamp`,
`too_many_properties`, `property_limit_exceeded`.

Because `received` counts identity pairs, `received: 1` from an identify-only request can mean
nothing was stored. The complete success test is: `rejected == 0`, `errors == []`, `received` equal
to the number of items you sent, and no `identify_ignored` key.

`traits_dropped` and `identify_ignored` exist because a silently ignored identify is the most
common "my data never arrived" report in this category of product, and the usual signal for it is a
warnings table nobody opens. Log both in debug mode.

### 5.5 Idempotency

Supply a stable `event_id` and a redelivered batch de-duplicates on merge. Omit it and a retry
double-counts. There is no other idempotency guarantee: no request-level key, no dedupe window you
can configure.

---

## 6. Identity

### 6.1 The model

Two ids, one graph. `device_id` is the anonymous actor. `user_id` is the person. An identify links
them, and from that point every read resolves the device to the person, including events written
*before* the link existed. That is what lets an anonymous visit and a signed-in purchase belong to
one customer.

Resolution order when reading, first match wins:

1. Explicit `user_id` on the row
2. The user linked to the row's `device_id`
3. `device:<device_id>` as a synthetic identity
4. `ip:<hash>` cohort, for rows that carry neither

### 6.2 First link wins

A `device_id` links to exactly one `user_id`, permanently. A second identify pointing the same
device at a different person is recorded but never wins: resolution picks the earliest link, so
the later one changes nothing. It is not reported in `identify_ignored`, because the server only
learns which link was first when it reads, not when it writes. This is deliberate: shared devices
otherwise merge two people's histories, and that cannot be unpicked afterwards. The practical
rule for an SDK is to call `reset()` on logout, so the next person gets a fresh device.

Send `identify` once per link, at sign-in and at sign-up. Sending it on every event is wasted
payload that counts against your batch size.

### 6.3 Blocked ids

Some values are never valid identities, because they are the string form of a bug rather than a
person, or a label rather than an individual:

`anonymous`, `guest`, `distinct_id`, `distinctid`, `device_id`, `deviceid`, `user_id`, `userid`,
`id`, `undefined`, `null`, `nan`, `none`, `true`, `false`, `0`, `[object object]`, and the empty
string.

Matching is case-insensitive and ignores surrounding whitespace, so `Anonymous` and ` NULL ` are
blocked too. The generated list is `spec/blocked-ids.json`.

Two of these deserve a warning because they are chosen deliberately rather than produced by a bug:
`anonymous` and `guest`. Using either as a `user_id` would collapse every logged-out visitor into
one person. Leave `user_id` absent instead and let the `device_id` carry the anonymous actor.

Blocked values are reported as `blocked_id` or `blocked_device_id` in `identify_ignored`.

### 6.4 Traits

Traits are attributes of the person, not of an event. They are written through the identify entry:

| Operation | Shape | Semantics |
|---|---|---|
| `$set` | object | Write, overwriting any existing value |
| `$set_once` | object | Write only if the key is absent |
| `$unset` | array of key names | Remove the key |

Contradictions are dropped rather than guessed: a key in both `$set` and `$set_once` has no
coherent intent, and neither does a key that is written and unset in the same request. Both are
reported in `traits_dropped`.

Values may be strings, numbers or booleans. `null` is not storable: once read back it is
indistinguishable from an absent key, so use `$unset` to remove something.

Limits: 128 bytes per key, 255 bytes per value, 100 keys per request, 8 KiB per request, and per
user 100 keys and 8 KiB total.

Five reserved keys have canonical `$`-prefixed spellings and are what the product displays as a
person's identity: `$email`, `$name`, `$username`, `$avatar`, `$created`. The bare spellings
(`email`, `name`, `username`, `avatar`, `created`, `createdat`) normalise into them, so an SDK may
send either.

---

## 7. POST /v1/identify

The single-entry form of the `identify` array in section 5, for clients that want a dedicated call.

```json
{ "device_id": "d_9f2c", "user_id": "u_8aa1", "$set": { "$email": "alex@acme.com" } }
```

`user_id` is required: without it there is `400 {"error": "user_id_required"}`. The entry must also
do something, so at least one of `device_id` (a link) or a trait operation must be present, or the
response is `400 {"error": "device_id_or_traits_required"}`.

The response is the `/v1/batch` shape. Read `identify_ignored` on it.

---

## 8. GET /v1/pixel.gif

No-JavaScript tracking, for email opens and pages where a script cannot run.

```
GET /v1/pixel.gif?_k=vnk_pk_...&e=email_opened&uid=u_8aa1&p_campaign=august
```

| Param | Meaning |
|---|---|
| `_k` | The write key. Required, since headers cannot be set |
| `e` | Event name. Required. Without it nothing is recorded |
| `did`, `uid`, `sid` | device id, user id, session id |
| `u`, `r` | page url, referrer |
| `p_<key>` | A string payload property, for example `p_plan=pro` |

**It always returns the 1x1 GIF**, whatever happens: bad key, missing event name, rate limit. The
status is always `200` and there is no way to detect success from the client. That is the point.
A tracking pixel that can return an error is a tracking pixel that can break somebody's email or
page rendering.

---

## 9. POST /v1/errors

Exception ingest. The Vinktar-native shape; section 11 covers the Sentry-compatible route.

```json
{
  "errors": [
    {
      "timestamp": "2026-08-19T22:43:12.881Z",
      "level": "error",
      "release": "web@1.4.2",
      "environment": "production",
      "user_id": "u_8aa1",
      "device_id": "d_9f2c",
      "exceptions": [
        {
          "type": "TypeError",
          "value": "cart.total is not a function",
          "stack": [
            { "file": "https://acme.com/main.9f2c.js", "line": 1, "col": 48213, "function": "t", "in_app": true, "debug_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }
          ]
        }
      ],
      "breadcrumbs": [{ "timestamp": "...", "category": "ui.click", "message": "#checkout" }],
      "tags": { "tier": "starter" },
      "request": { "url": "https://acme.com/checkout", "method": "POST", "headers": {} },
      "context": { "browser": "Chrome", "browser_version": "141" }
    }
  ],
  "context": { "lib": "vinktar-js", "lib_version": "1.4.0" }
}
```

**Cap: 50 errors per request.** Over that is `413 {"error": "batch_too_large"}`.

### 9.1 Frame and exception order

- Frames are **crash-last**: the innermost frame, where the throw happened, is the final element.
- Exceptions are **thrown-first**: the exception that was raised comes first, its cause after it.
- Columns are **0-based**.
- A frame is `{ file, line, col, function, in_app }` plus, optionally, `debug_id`: a string of at
  most 64 bytes read from the SDK's debug-id registry (the `//# debugId=` comment the bundler
  injected into the minified file). Symbolication matches a frame to an uploaded map by
  `debug_id` first, then by `release` plus the normalised `file` path, so a frame with an
  unknown `debug_id` still resolves when the map was uploaded under a URL.

Getting either order backwards produces stack traces that read inside out, and grouping that
fingerprints the wrong frames. `spec/limits.json` states all three as machine-readable values so a
generated SDK cannot drift.

`level` is one of `fatal`, `error`, `warning`, `info`. `mechanism`, where you send it, is one of
`onerror`, `onunhandledrejection`, `manual`.

### 9.2 Limits

5 exceptions per error, 50 frames each. Type 256 bytes, message 8 KiB, raw stack 16 KiB, frame
strings 512 bytes, the whole exceptions block 256 KiB. 50 breadcrumbs within 32 KiB. 32 tags, keys
32 bytes, values 200 bytes. 8 fingerprint parts of 128 bytes. Request block: 50 headers with keys
of 128 bytes and values of 1 KiB, url and query 1 KiB, `data` 8 KiB serialised, whole block 16 KiB,
which sheds `data` first and then `headers` when it is over. Context follows the event rules: 255
properties, 255 bytes, depth 3.

### 9.3 Grouping

Fingerprinting is server-side and resistant to minification. The order is:

1. An explicit SDK `fingerprint`, if you send one
2. Exception type plus the top five in-app normalised frames, with origin, query strings and
   content-hash segments stripped, and no line or column numbers
3. A templated form of the message

**Do not send a fingerprint per occurrence.** That is one issue per event, which makes the issue
list useless and cannot be undone after the fact.

### 9.4 Suppression, and what you are charged for

Two things run before storage: a default inbound filter that drops known browser noise and stacks
composed entirely of browser-extension frames, and a PII scrubber.

Suppressed occurrences are dropped, are **not** stored, and are **not** counted against the monthly
error budget. Dropped errors are free.

### 9.5 Response

```json
{ "received": 3, "rejected": 0, "suppressed": 1, "errors": [] }
```

`received` counts only errors that survived the filter. If your own error appears to vanish, check
`suppressed` before assuming a bug: it means the filter matched on purpose. `traits_dropped`
appears here too, with the same meaning as in section 5.4.

---

## 10. POST /v1/sourcemaps

Upload source maps so minified stacks resolve. Requires a `cli` scope key, not a write key. This is
a build-time call: run it from CI, never from a browser.

`multipart/form-data` with:

| Field | Meaning |
|---|---|
| `release` | Required. The same release string your events and errors carry |
| `dist` | Optional build discriminator |
| `files[]` | The map files |
| `urls[]` | The public URL each map belongs to |
| `debug_ids[]` | Optional. Tried before release plus URL when matching |

**Every file needs one of `urls[]` or `debug_ids[]`.** A file with neither is
`400 {"error": "missing_url_or_debug_id"}`, so `release` plus `files[]` alone is not a valid
request. A debug id is also read out of the file itself when the bundler embedded one.

Limits: 20 MiB per file, 60 MiB per request, and your plan's total source map storage. Exceeding
them is `413` with `file_too_large`, `payload_too_large`, or
`quota_exceeded` with `quota_bytes` and `used_bytes`.

Response is `201`:

```json
{ "stored": 2, "artifacts": [{ "file_url": "...", "debug_id": "...", "size": 12345 }] }
```

**Late uploads heal history.** Occurrences that arrived before their maps are re-symbolicated back
through the last 7 days, up to 5,000 occurrences per release. Uploading after deploy is the normal
case, not an error case.

---

## 11. Sentry compatibility

For migration, and for languages where a Vinktar SDK does not exist yet. Point any Sentry SDK's DSN
at Vinktar:

```
https://<vinktar_write_key>@in.vinktar.com/<projectId>
```

- `POST /api/{projectId}/envelope/` is the modern envelope protocol
- `POST /api/{projectId}/store/` is the legacy single-event endpoint

The DSN public key **is** the Vinktar write key. It is read from `X-Sentry-Auth` (`sentry_key=`) or
from the DSN userinfo. The DSN project id must match the key's project, the same check Sentry
performs, and a mismatch is `401 project_mismatch`.

Transactions, sessions and replays are accepted and discarded, so an existing SDK does not error.
Only errors are stored. Do not read that as "supported": there is no tracing product here.

### 11.1 Identity on this path

A Sentry error resolves to a person the same way an event does. The translator fills the ids from
the payload where the SDK provides them:

| Vinktar field | Read from, first match wins |
|---|---|
| `user_id` | `user.id` |
| `device_id` | `contexts.device.device_id`, else `contexts.app.device_app_hash` |
| `session_id` | `contexts.replay.replay_id`, else `contexts.trace.trace_id` |

Browser Sentry SDKs send none of these by default, so a browser error lands on the `ip:<hash>`
cohort. Be clear about what that is and is not: it stops every anonymous error resolving to one
phantom user, and it does **not** line the error up against that visitor's analytics, because their
events carry a `device_id` and resolve to `device:<id>` instead.

**To get the join, call `Sentry.setUser({ id })` once**, with the same id you send as `user_id` on
the analytics side. That single line is the difference between a cohort guess and a named person.

---

## 12. GET /v1/health

```json
{ "status": "ok", "cache": true, "db": true, "ch": true }
```

`200` only when Valkey, Postgres and ClickHouse are all reachable, otherwise `503` with
`"status": "degraded"` and the failing check as `false`. Suitable for an external uptime check.

It does not cover the Messenger worker. A green health check with a dead worker means errors ingest
fine and their stack traces stay minified until it comes back.

---

## 13. Things you will look for and not find

Documented because guessing wrong here is expensive.

- **No per-event byte cap.** The item-count and property limits are the contract. A single enormous
  event is bounded only by the request ceiling it travels in.
- **No idempotency beyond `event_id`.** No request-level key, no server-side dedupe window.
- **No event-name allowlist or schema.** Nothing rejects a typo, so a client that generates names
  dynamically will quietly create new ones forever.
- **No batch-level partial-failure status.** A batch is `202` even when every event in it was
  rejected. Read `rejected` and `errors`.
- **No update or delete API for events.** The stream is append-only. Deletion is a tenant-level
  purge, not a per-row operation.
- **No sampling controls on the wire.** If you want sampling, do it in the client before sending.
- **No per-event server-side PII scrubbing on the analytics path.** Scrubbing runs on the error
  path. Do not send secrets as event properties.
- **No webhook or streaming read API.** Reads are GraphQL, session-authenticated, and out of scope
  for this document.

---

## 14. Conformance checklist

An SDK that satisfies all of these is correct against this contract. `spec/PROTOCOL.md` expands the
client-side behaviour behind several of them.

**Transport**

1. Sends `X-Vinktar-Key`, never `X-Vinktar-ApiKey`.
2. Sends no request header outside `Content-Type`, `Content-Encoding` and `X-Vinktar-Key`.
3. Gzips bodies over roughly 1 KiB and sets `Content-Encoding: gzip`.
4. Splits at 1,000 items per batch and 50 per error batch client-side, rather than discovering the
   cap through a `413`.
5. Keeps request bodies under 5 MiB, splitting on byte size as well as item count.

**Responses**

6. Treats `202` as durable and frees the buffer.
7. Reads `rejected` and `errors` and exposes them in debug logging.
8. Reads `traits_dropped` and `identify_ignored` and exposes them the same way.
9. Reads `suppressed` on the error path before reporting an error as lost.
10. Branches on the `error` code, never on the status alone.
11. Stops on `monthly_cap_exceeded` and `monthly_error_cap_exceeded` instead of sleeping until next
    month.
12. Honours `X-RateLimit-Categories`, holding only the listed categories.
13. Retries `503` with the same batch, and never counts it as delivered.

**Data**

14. Sends a stable `event_id` per event whenever it may retry.
15. Sends `device_id` on every event it can, and `user_id` whenever it is known.
16. Calls identify once per link, not once per event.
17. Never sends a blocked id from `spec/blocked-ids.json` as an identity.
18. Normalises reserved traits to their `$`-prefixed spellings, or sends the bare form and lets the
    server do it, but never invents a third spelling.
19. Emits frames crash-last, exceptions thrown-first, columns 0-based.
20. Never sends a per-occurrence fingerprint.
21. Enforces the documented limits client-side and reports what it dropped in `client_report`
    rather than sending data that will be rejected.

---

## 15. Changing this contract

Field names, error codes and response keys are promises. Add, never rename. When a limit changes,
change the constant in the server first, regenerate `spec/limits.json` with
`spec/bin/generate-limits.php`, and let this document follow. Check the claim against the code
before it goes in here: an earlier revision of this file stated that there was no request byte-size
limit and that gzip was handled only on the Sentry path. Both were wrong the whole time it said so.
