# Ingestion API

The ingestion endpoints are what the SDKs call to send data. They are authenticated by a project token, not the admin key. Send the token in the X-Cohorly-Token header (applies to the whole batch) or per record (which wins over the header).

## Authentication

Pass your project token via the `X-Cohorly-Token` header. An unknown or
missing token returns `401` with `{ "status": 0, "error": "invalid token" }`.
Per-record tokens (`properties.token` on `/track`, the `token` field on
`/engage` and `/alias`) override the header and are stripped before storage.

## Limits

| Limit                    | Value                       | On exceed                 |
| ------------------------ | --------------------------- | ------------------------- |
| Batch size               | 500 records per request     | `400` malformed/too large |
| Request body             | 1 MB                        | `413` payload too large   |
| Rate limit (per IP)      | 100 requests / 10 seconds   | `429` rate limited        |
| Rate limit (per project) | 1,000 requests / 10 seconds | `429` rate limited        |

The rate limit applies to `/track` and `/engage`. See
[Rate limits](/api/rate-limits) for the full 429 contract, including the
per-project limit, the monthly event quota, and recommended retry behavior.

## Track events

POST /track Record one event, or a batch of up to 500.

The body is a single event object or an array of them (max 500). Each event
needs an `event` name and a `properties` object containing at least
`distinct_id`. The server enriches every stored event with receive time,
processing time, payload size, and geo from the client IP - client-sent
values are never overridden.

An event carrying both `$device_id` and `$user_id`, with the two different
and `distinct_id` equal to `$user_id`, implicitly links `$device_id`'s
history into `$user_id`'s identity - no separate `/alias` call needed. See
[Identity](/reference/data-model#identity) and [Link an
identity](#link-an-identity) below.

event string Event name, for example Signed Up.

properties object Event properties; must include distinct\_id.

properties.distinct\_id string Who this event belongs to.

properties.time integer Unix ms. Defaults to server receive time if omitted.

properties.$insert\_id string Client-generated idempotency key for deduplication.

properties.token string Per-event project token; overrides the X-Cohorly-Token header for this event, then is stripped before storage.

properties.\* any Any other JSON-serializable property is stored as-is.

```bash
curl -X POST https://cohorly-service.velloalabs.com/track \
  -H "Content-Type: application/json" \
  -H "X-Cohorly-Token: YOUR_PROJECT_TOKEN" \
  -d '[
    {
      "event": "Signed Up",
      "properties": { "distinct_id": "user_123", "plan": "pro" }
    },
    {
      "event": "Page Viewed",
      "properties": {
        "distinct_id": "user_123",
        "time": 1753900800000,
        "$insert_id": "b1f2c3d4-...",
        "path": "/pricing"
      }
    }
  ]'
```

```js
await fetch("https://cohorly-service.velloalabs.com/track", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Cohorly-Token": "YOUR_PROJECT_TOKEN",
  },
  body: JSON.stringify([
    { event: "Signed Up", properties: { distinct_id: "user_123", plan: "pro" } },
  ]),
});
```

```json
{ "status": 1, "inserted": 2 }
```

```json
{ "status": 0, "error": "invalid json" }
```

```json
{ "status": 0, "error": "invalid token" }
```

```json
{ "status": 0, "error": "payload too large" }
```

```json
{ "status": 0, "error": "rate limited" }
```

## Update user profiles

POST /engage Set, increment, or delete profile properties for a distinct\_id.

The body is a single operation or an array of them (max 500). Each operation
targets a `distinct_id` and carries one or more of the profile update verbs.
`$set`/`$set_once` ops receive Mixpanel-parity geo and `$last_seen` defaults
merged in server-side (client-sent keys always win).

distinct\_id string The profile to update.

$set object Set properties, overwriting existing values.

$set\_once object Set properties only if not already present.

$add object of numbers Increment numeric properties.

$unset string\[] Remove the named properties. Requires an owner or superadmin credential - see below.

$delete boolean Delete the entire profile row. Requires an owner or superadmin credential - see below.

token string Per-op project token; overrides the X-Cohorly-Token header.

```bash
curl -X POST https://cohorly-service.velloalabs.com/engage \
  -H "Content-Type: application/json" \
  -H "X-Cohorly-Token: YOUR_PROJECT_TOKEN" \
  -d '{
    "distinct_id": "user_123",
    "$set": { "name": "Ada Lovelace", "plan": "pro" },
    "$add": { "logins": 1 }
  }'
```

```js
await fetch("https://cohorly-service.velloalabs.com/engage", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Cohorly-Token": "YOUR_PROJECT_TOKEN",
  },
  body: JSON.stringify({
    distinct_id: "user_123",
    $set: { name: "Ada Lovelace", plan: "pro" },
    $add: { logins: 1 },
  }),
});
```

```json
{ "status": 1, "applied": 1 }
```

```json
{ "status": 0, "error": "invalid json" }
```

```json
{ "status": 0, "error": "invalid token" }
```

```json
{ "status": 0, "error": "payload too large" }
```

```json
{ "status": 0, "error": "rate limited" }
```

### Destructive verbs need more than the project token

The project token ships in your public client bundle, so `$unset` and
`$delete` are not honored on the token alone. Send an
`Authorization: Bearer` header with a credential valid for the admin API -
your superadmin API key, or a Firebase ID token for an **owner** of the org
that owns the target project. Ops without it are refused individually: the
rest of the batch still applies, and the response stays HTTP `200` so SDK
retry queues are never wedged (a `4xx` here would make retry-on-failure
queues replay non-idempotent `$add` ops). `$set`, `$set_once` and `$add`
continue to work with the project token alone.

```json
{
  "status": 0,
  "error": "$delete/$unset require an owner or superadmin credential",
  "applied": 1,
  "refused": [{ "index": 1, "op": "$delete" }]
}
```

## Link an identity

POST /alias Link an id into a known id's identity.

Both `alias` and `distinct_id` are required. This links `alias` (typically an
anonymous id) into `distinct_id`'s identity cluster, so both resolve to one
person. Whether it rewrites history or only routes future events depends on
`alias`'s own stored history: if everything ever stored under `alias` looks
like an anonymous, device-minted id - or nothing is stored under it at all -
the link is retroactive, and events already stored under `alias` are
rewritten onto `distinct_id`. Otherwise (`alias` already has events of its
own, including from a previously identified user) the link only takes effect
going forward: new events under `alias` route to `distinct_id`, but nothing
already stored moves. Either way it cannot be undone. See
[Identity](/reference/data-model#identity) for the full mechanics, including
the other cases where a link is silently refused (for example, `alias`
already belongs to a different cluster) - the response below does not change
in any of these cases, since a governance decision must never wedge an SDK
retry queue.

alias string The (usually anonymous) id being linked in.

distinct\_id string The id whose identity it joins.

token string Per-item project token; overrides the X-Cohorly-Token header.

```bash
curl -X POST https://cohorly-service.velloalabs.com/alias \
  -H "Content-Type: application/json" \
  -H "X-Cohorly-Token: YOUR_PROJECT_TOKEN" \
  -d '{ "alias": "anon_abc", "distinct_id": "user_123" }'
```

```js
await fetch("https://cohorly-service.velloalabs.com/alias", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Cohorly-Token": "YOUR_PROJECT_TOKEN",
  },
  body: JSON.stringify({ alias: "anon_abc", distinct_id: "user_123" }),
});
```

```json
{ "status": 1 }
```

```json
{ "status": 0, "error": "alias and distinct_id required" }
```

```json
{ "status": 0, "error": "invalid token" }
```

```json
{ "status": 0, "error": "rate limited" }
```
