# /api/beat

> The Kymo ingest endpoint contract — request shape, what the server drops, what it stores, and the response codes.

Canonical: https://kymo.in/docs/reference/api-beat
Last updated: 2026-07-21

`POST /api/beat` is the ingest endpoint. `beat.js` calls it; you normally do not call it directly. This documents its contract.

## Request

- **Method** — `POST`. `OPTIONS` returns `204` for CORS preflight.
- **Content-Type** — `text/plain`. This is deliberate: `application/json` would trigger a CORS preflight that `sendBeacon` cannot satisfy cross-origin. The body is still JSON, just sent as text.
- **Body** — JSON:

| Name | Type | Required | Description |
|---|---|---|---|
| `site` | string | yes | Domain to file the event under. Missing site returns 400. |
| `path` | string | yes | Page path. Missing path returns 400. Capped at 200 chars. |
| `kind` | string | no | "outbound" or "goal"; anything else is treated as a pageview. |
| `target` | string | no | Outbound URL or goal name. Capped at 300 chars. |
| `value` | number | no | Revenue, stored only for goals. |
| `ref` | string | no | Referrer URL; only the hostname is kept, and self-referrals are dropped. |
| `us / um / uc` | string | no | UTM source / medium / campaign, kept only on pageviews. |
| `w` | number | no | Viewport width; width under 768 or a mobile UA classifies the device as mobile. |

CORS is open (`Access-Control-Allow-Origin: *`) so any origin can post.

## What the server drops

Before storing, the endpoint drops the request — returning `204` with nothing written — when:

- The user-agent matches `bot`, `crawl`, `spider`, `slurp`, `headless`, or `lighthouse`.
- The request claims to be desktop Chrome or Edge on an https page but carries no client hints. A real Chrome or Edge 90+ always sends them, so this is a headless browser with a copied user-agent.
- The request IP is in the site's excluded-IP list.

The raw IP and user-agent are used only to derive the visitor hash, the device, and the browser, then discarded — never stored. See [What we store](https://kymo.in/docs/trust/what-we-store).

## Responses

| Name | Type | Required | Description |
|---|---|---|---|
| `204` | No Content | no | Accepted, or intentionally dropped (bot / excluded IP). Both look identical to the client. |
| `400` | Bad Request | no | Missing site or path. Body: { error }. |
| `500` | Server Error | no | Storage failed. Body: { error }. |

> **Note:** A `204` does not confirm the event was stored — it is also returned for dropped bot traffic and excluded IPs. When debugging, confirm the `site` value in the request body matches a registered domain; a mismatch is stored under the wrong site, not rejected.

## A related endpoint

AI crawlers are ingested through a separate endpoint, `/api/crawl`, which your server (not the browser) posts `{ site, path, ua, status }` to. That path classifies the user-agent and stores a `bot` event. It is out of scope here; see [The bot taxonomy](https://kymo.in/docs/concepts/bots).
