# HTTP ingest API

> The tracker's ingest endpoint, its payload contract, and a plain statement of what it is not: a supported server-side tracking API.

Section: Reference. Canonical page: https://mrkr.app/docs/reference/http-api. Last updated: 2026-09-02.

There is one ingest endpoint, and it is built to receive a genuine browser beacon. It is documented here so you can debug what your own pages are sending, not so you can send to it yourself.

> **There is no server-side tracking API:** mrkr has no backend event API, no API key, and no supported way to post events from a server, a script, or curl. Requests that don't look like a real browser are classified as bots and discarded. Read [What this is not](#what-this-is-not) before you build anything against this endpoint.

## The endpoint

`POST /api/collect` (aliased as `/api/data` on a managed proxy subdomain). The body is JSON. The tracker sends it as `Content-Type: text/plain`, which makes the request a CORS simple request and skips the preflight round trip; the server parses the body as JSON whatever the declared type. CORS is open (the endpoint replies with `Access-Control-Allow-Origin: *`) because the whole point is accepting cross-origin beacons from whatever domain a customer's site runs on.

## The envelope

One request carries a batch. The identity and browser-context fields sit once on the envelope, and each event in `events` carries its own name, props, and path:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `site` | string | Yes | Site id. Missing or empty returns 400. |
| `events` | array | No | Up to **25** entries per request; anything beyond that is discarded. Each entry takes `event`, `props`, `path`, `url`, `t`. |
| `event` | string | No | Single-event shorthand, used when `events` is absent or empty. Defaults to `"pageview"`. |
| `props` | object | No | Sanitized with the same 8-key / 256-char rules `track()` uses client-side. |
| `session` | string | No | Without it, the event still passes the bot gate and updates rollup counters, but skips the sessions upsert and the events-table mirror entirely. |
| `client_id` | string | No | Cookie-mode persistent visitor id. Ignored on a cookieless site. |
| `path`, `url`, `referrer`, `utm` | string / object | No | Used for page/source classification. A per-entry `path` wins over the envelope's. |
| `tz`, `lang`, `screen`, `ua` | string | No | Browser context; `tz` also feeds the geo/timezone bot check. |
| `wd`, `pr`, `nl`, `vp`, `nb` | boolean | No | Client-side bot signals (webdriver, prerendering, empty languages, zero viewport, empty UA-data brands). Omitting them just means those specific checks can't fire. |

> **Five event names are refused outright:** `click_link`, `click_button`, `submit_form`, `select_change` and `toggle_change` are the retired autocapture names. They are dropped before any write, with no rollup, no event row, and no session marker consumed, so a tracker still cached in a visitor's browser cannot keep writing click data. Do not reuse these names for your own events.

## What this is not

> **Server-side event ingestion is not a supported feature:** There is no API key, no server-side authentication, and no documented, stable contract for posting events from a backend process. The gate that decides whether a request counts as real traffic assumes a genuine browser: it requires an `Origin` header that resolves to the site's configured domain, a `Sec-Fetch-Site` header (which only real browsers attach to fetch/sendBeacon requests), and a User-Agent that starts with `Mozilla/5.0` and passes header-consistency and pattern checks. A plain POST from a server, a script, or curl fails these by construction and is classified and discarded as a bot, silently, with a 200 response that looks successful.

Do not try to work around the gate. There is no authentication model, no stability commitment on the payload contract, and no rate-limit tier designed for backend traffic, so anything built that way will break without notice and its numbers will not be trustworthy in the meantime. If your product needs backend-originated events, [tell us](https://mrkr.app/contact) and we will treat it as the feature request it is.

## Response shape

| Situation | HTTP status | Body |
| --- | --- | --- |
| Accepted | 200 | `{ ok: true, accepted: n, replay: { enabled, sample_rate } }` |
| Classified as a bot | 200 | `{ ok: true, bot: true, reason }` |
| Every event in the batch was on an excluded path | 200 | `{ ok: true, excluded: true }` |
| Every event in the batch was a retired autocapture name | 200 | `{ ok: true, dropped: "autocapture" }` |
| Hard pageview quota exceeded (grandfathered free plan only) | 200 | `{ ok: true, quota: "exceeded" }` |
| Malformed JSON or missing `site` | 400 | `{ ok: false, error }` |
| Unknown `site` | 404 | `{ ok: false, error: "unknown site" }` |

> **Warning:** Every rejection short of a malformed request or an unknown site still returns HTTP 200. The tracker is designed to never retry a rejected event, so a 200 does not mean the event was stored. If you're probing this endpoint directly, you have to read the body, not just the status code.

For the client-side API this endpoint exists to serve, see [the JavaScript API reference](https://mrkr.app/docs/reference/javascript-api). For what triggers a bot classification, see [bots and spam](https://mrkr.app/docs/tracking/bots-and-spam).
