# Wire protocol (https://www.getfounderhq.com/docs/analytics/protocol-reference/envelope)

This page describes the exact bytes an SDK sends and gets back. Read it
when you write your own client, debug a payload, or point an AI agent at
the contract.

<Callout title="You rarely need this">
  The web, React Native, iOS, Android, and Node SDKs already speak this
  protocol. Start at [Getting started](/analytics/getting-started) unless you
  are building a client yourself.
</Callout>

## Send the batch

Send one `POST` to `/i/v2/e` on your FounderHQ host. Put the publishable
key in the `Authorization` header.

```http
POST https://i.getfounderhq.com/i/v2/e
Authorization: Bearer fhq_pk_XXXX
Content-Type: application/json
```

The body is an envelope with a send time and a list of events.

```json
{
  "sent_at": "2026-05-23T09:41:02.115Z",
  "batch": [
    {
      "uuid": "0197f6b8-2c31-7a4e-9f0d-5b2c1d8e4a77",
      "event": "trial.started",
      "distinct_id": "user_42",
      "timestamp": "2026-05-23T09:41:01.980Z",
      "properties": { "plan": "pro", "$pathname": "/pricing" },
      "session_id": "0197f6b8-1a02-7c11-8f31-6d0b9e2a4c58",
      "options": { "process_person_profile": false }
    }
  ]
}
```

A batch holds 1 to 100 events. You may gzip the body: set
`Content-Encoding: gzip`, or add `?compression=gzip-js` to the URL.

## Envelope fields

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `sent_at` | ISO 8601 date-time | yes | When the client sent the request. FounderHQ uses it to correct clock drift. |
| `batch` | array | yes | 1 to 100 events. |

## Event fields

Normal and cookieless events use the same fields.

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `uuid` | UUID string | yes | Your idempotency key. Send the same UUID twice and FounderHQ counts it once. |
| `event` | string | yes | The event name. Max 200 characters. |
| `distinct_id` | string | yes | Who did it. 1 to 400 characters. |
| `timestamp` | ISO 8601 date-time | yes | When it happened on the device. |
| `properties` | object | yes | Everything else about the event. There is no separate `context` object. |
| `session_id` | string | no | The session the event belongs to. |
| `window_id` | string | no | The browser tab or window. |
| `options` | object | yes | Capture and profile-processing hints. See below. |

`$pageview_id` belongs in `properties`, not at the event's top level.

### Options

| Field | Values | Meaning |
| --- | --- | --- |
| `process_person_profile` | boolean | Whether this event may update the contact profile. Required. |
| `cookieless_mode` | boolean | `true` marks the strict cookieless event shape. Omit it for normal events. |

### Event names

Names that do not start with `$` are yours. Use any name up to 200
characters.

Names that start with `$` are reserved. FounderHQ rejects any `$` name
that is not on the allowlist. See
[Event taxonomy](/analytics/protocol-reference/event-taxonomy).

### Distinct IDs FounderHQ refuses

A distinct ID must identify one person. FounderHQ drops these values,
whatever their letter case or surrounding spaces:

`""`, `anonymous`, `guest`, `id`, `undefined`, `null`, `none`, `nil`,
`nan`, `[object object]`, `"undefined"`, `"null"`

Send a real ID, or send your own anonymous ID. Never send the literal
string `anonymous`.

## Read the response

An accepted request returns HTTP `202` with one result per event.

```json
{
  "results": [
    { "uuid": "0197f6b8-2c31-7a4e-9f0d-5b2c1d8e4a77", "status": "ok" }
  ]
}
```

Results come back per UUID, not per batch. One bad event never sinks the
other 99.

| Status | What it means | What your client does |
| --- | --- | --- |
| `ok` | FounderHQ stored the event. | Drop it from the queue. |
| `warning` | FounderHQ accepted the event, but changed something. The `message` says what. A duplicate UUID and a corrected timestamp both report `warning`. | Drop it from the queue. |
| `drop` | FounderHQ refused the event and will always refuse it. Common causes: an unknown `$` name, an illegal distinct ID, properties over the size limit. | Drop it. Retrying wastes calls. |
| `retry` | Something transient failed. | Keep the event queued and send it again. |

Every result may carry a `message`. Log it — it names the exact reason.

### Request-level failures

| HTTP | Meaning |
| --- | --- |
| `400` | The envelope is not valid JSON, does not match the shape, or mixes consent states. |
| `401` | The key is missing, wrong, or not a publishable key. |
| `403` | The key cannot capture events, is not scoped to a brand, or the request origin is not allowed. |
| `413` | The body is too large. Send a smaller batch. |
| `429` | Your organization is over its ingest budget. Read `Retry-After` and wait. |
| `503` | FounderHQ could not check the budget. Retry with backoff. |

## Size ceilings and rate limits

Both ingest paths — `/i/v2/e` for publishable keys and `/api/events` for
secret keys — share these ceilings.

| Limit | Value | Over it |
| --- | --- | --- |
| Events per batch | 100 | `400` |
| Request body on the wire | 256 KiB | `413` |
| Body after gzip decompression | 1 MiB | `413` |

Budgets are per organization. Every brand, every key, every site, and every
installation spends from the same allowance. Creating another key does not
buy more capacity.

| Budget | Sustained | Burst |
| --- | --- | --- |
| Requests | 200 per second | 400 |
| Events | 500 per second | 2,000 |
| Decoded body bytes | 2 MiB per second | 8 MiB |

When a limit applies to your organization, FounderHQ answers `429` with a
`Retry-After` header in whole seconds, before it reads or stores anything.
Nothing in that batch was accepted. Keep the events queued, wait the stated
delay, and send the same UUIDs again.

`Retry-After` and `Server-Timing` are exposed to browser clients, so a
web SDK can read them across origins.

## Identity directives

Sometimes FounderHQ decides your anonymous visitor must start a new
anonymous identity. That happens when the identity you sent already
belongs to somebody else. FounderHQ says so in the response:

```json
{
  "results": [
    {
      "uuid": "0197f6b8-2c31-7a4e-9f0d-5b2c1d8e4a77",
      "status": "warning",
      "message": "Anonymous identity rotated; retry identify"
    }
  ],
  "directives": [{ "type": "rotate_distinct_id" }]
}
```

Your client must do two things:

1. Replace the stored anonymous ID. Use the `distinct_id` in the
   directive when it is present. Otherwise generate a fresh UUID.
2. Send the `$identify` event again with a new `uuid`, and set
   `$anon_distinct_id` and `$device_id` in its properties to the new
   anonymous ID.

The official SDKs do this for you. Skip the step and the person stays
split across two identities.

## Normal and cookieless events

FounderHQ supports cookieless measurement for visitors who have not granted
tracking. A cookieless event uses the normal event shape with a fixed
non-person identity and can never touch a contact profile.

```json
{
  "uuid": "0197f6b8-4d92-7b30-a1c7-e2f4a90b6d13",
  "event": "$pageview",
  "distinct_id": "$founderhq_cookieless",
  "timestamp": "2026-05-23T09:41:01.000Z",
  "properties": {
    "$pathname": "/pricing",
    "$referring_domain": "news.ycombinator.com",
    "utm_source": "hn",
    "$platform": "web"
  },
  "options": {
    "cookieless_mode": true,
    "process_person_profile": false
  }
}
```

Differences from a granted event:

| | Normal | Cookieless |
| --- | --- | --- |
| `consent_state` | not used | not used |
| `distinct_id` | visitor or contact ID | `"$founderhq_cookieless"` |
| `event` | any allowed name | `$pageview` or `$pageleave` only |
| `properties` | any | a fixed allowlist only |
| `session_id`, `window_id`, `$pageview_id` | allowed | not allowed |
| `options` | `process_person_profile` | also requires `cookieless_mode: true` and `process_person_profile: false` |

The allowed cookieless properties are listed on
[Event taxonomy](/analytics/protocol-reference/event-taxonomy). Two of them
are coarsened on purpose: FounderHQ rounds `$page_duration_ms` to whole
seconds and `$max_scroll_percentage` to steps of 5.

Normal and cookieless events may share one batch. FounderHQ validates, routes,
and acknowledges every event independently by UUID.

## What FounderHQ does not accept

- There is no `context` object. Protocol v2 has one property namespace.
- The older `/api/ingest/batch` contract does not work. Use `/i/v2/e`.
- The publishable-key path cannot write `$revenue` or
  `$account_membership`. Those need the revenue API and a secret key.
