Wire protocol
The HTTP request and response FounderHQ Events SDKs use.
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.
You rarely need this
The web, React Native, iOS, Android, and Node SDKs already speak this protocol. Start at Getting started unless you are building a client yourself.
Send the batch
Send one POST to /i/v2/e on your FounderHQ host. Put the publishable
key in the Authorization header.
POST https://i.getfounderhq.com/i/v2/e
Authorization: Bearer fhq_pk_XXXX
Content-Type: application/jsonThe body is an envelope with a send time and a list of events.
{
"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.
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.
{
"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:
{
"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:
- Replace the stored anonymous ID. Use the
distinct_idin the directive when it is present. Otherwise generate a fresh UUID. - Send the
$identifyevent again with a newuuid, and set$anon_distinct_idand$device_idin 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.
{
"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. 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
contextobject. Protocol v2 has one property namespace. - The older
/api/ingest/batchcontract does not work. Use/i/v2/e. - The publishable-key path cannot write
$revenueor$account_membership. Those need the revenue API and a secret key.