Send events
POST /api/v1/ingest/events: up to 500 events per request, with their fields, visitor matching and timestamps.
POST https://pilotfox.app/api/v1/ingest/eventsRecords a batch of events. It needs an API key with ingest:write (see
Server-side ingest for the key, the limits and how events are matched to visitors). Reference:
POST /api/v1/ingest/events.
Request
A JSON object with one field, events: an array of 1 to 500 events, at most 1 MiB in all.
{
"events": [
{ "site": "SITE_ID", "type": "event", "name": "subscription_renewed", "anonymous_id": "customer_8412" }
]
}Each event has the fields of a browser event, plus four only the server can send (ip, user_agent,
anonymous_id, ts), all optional. Like the browser's, events are read tolerantly: text that is too long is cut
to its limit, a value PilotFox cannot use is dropped, and unknown fields are ignored, rather than refusing the event.
The four server fields are the exception: a value of the wrong form refuses the event instead of being dropped, so an
event is never filed under the wrong visitor or time. Only the problems listed under rejections
refuse an event.
Event fields
| Field | Required | Type and limit | What it is |
|---|---|---|---|
site | yes | string | The site's public id. |
type | yes | pageview, event, outbound, click, form, input, copy, error or vital | The kind of event. Custom events are event. |
name | for event and error | string, 128 characters | The event's name, such as subscription_renewed, or an error's name. Spaces around it are trimmed. |
url | no | absolute URL, 2 048 characters | The page the event happened on. Its path, query string and UTM parameters are read as for a browser event. Without it, the site's home page (https:// and the site's domain) is used. A relative URL rejects the event. |
ref | no | string, 2 048 characters | The referrer URL. |
title | no | string, 512 characters | The page title. |
props | no | object | Custom properties: the first 30, each value a string (cut to 512 characters), a number or a boolean. A key longer than 64 characters and other values (objects, arrays, null) are dropped. On an error event, props are not stored: the error's own fields (err) are. |
uid | no | string, 255 characters | Your user id: reports count the event for that user. A number is read as text; a value with no text in it is no user. |
lang | no | string, 35 characters | The visitor's language, such as en-US. |
sw, sh | no | integers, 0 to 65 535 | Screen width and height in pixels. |
tag | no | string, 64 characters | A free label for the session (filterable as the tag dimension). |
err | no | object | Read on error events only: message (1 000 characters), stack (4 000), file (512), line and col (integers). An error event without it is stored with an empty error. Errors are grouped by name, message and top stack frame. |
vitals | no | object | Read on vital events only: lcp, cls, inp, fcp, ttfb, non-negative numbers. |
ip | no | string: an IPv4 or IPv6 address | The visitor's IP address. Without it, the address the request came from (your server's) is used. Anything but a valid address, null included, rejects the event. |
user_agent | no | string, 512 characters kept | The visitor's User-Agent. Without it, the event has no browser, system or device. A value that is not a string rejects the event. |
anonymous_id | no | string, 128 characters kept | Your own stable id for the visitor; takes precedence over ip and user_agent. An empty string counts as none; a value that is not a string rejects the event. |
ts | no | string: an RFC 3339 date and time, with seconds and Z or a ±hh:mm offset | When the event happened, from 7 days ago to 5 minutes ahead, such as 2026-10-27T18:05:00+02:00 or 2026-10-27T16:05:00.250Z. A time without seconds, without an offset, with a lowercase z or with an offset like +0200 rejects the event. Without ts, the time PilotFox received the request. |
Which visitor an event belongs to depends on ip, user_agent and anonymous_id: see
Matching server events to visitors.
Timestamps and late events
ts lets you send events after the fact, for example from a queue or a retry: up to 7 days late. An event dated more
than 30 minutes ago does not join the visitor's current session. It goes to a session of its own for that visitor and
that 30-minute period, so a backlog sent twice lands in the same sessions, and it never shows in the realtime view.
PilotFox does not deduplicate events: a batch sent twice is stored twice. If a request fails without an answer (a network error, a timeout), you cannot tell whether its events were stored; decide whether a retry or a gap matters more to you.
Response
200 with how many events were taken and which were rejected, by their position in events (counting from 0):
{
"data": {
"accepted": 2,
"rejected": [
{ "index": 2, "code": "invalid" },
{ "index": 3, "code": "forbidden_site" },
{ "index": 4, "code": "ts_out_of_range" },
{ "index": 5, "code": "invalid" }
]
}
}A batch is not all-or-nothing: the events that pass are stored even when others are rejected. Check rejected on every
answer.
accepted counts every event PilotFox took, including those it then drops on purpose: events excluded by the site's
settings (an excluded IP address, network, country, path or user agent, or a hostname not on the allowed list) and,
when the site blocks bots, events from a detected bot. The answer does not say which, as for the tracking script.
Rejected events
code | Why |
|---|---|
invalid | Not an object, or a required field is missing or unusable: no site, an unknown type, no name on an event or error, a relative url. Or a server field of the wrong form: an ip that is not an IP address, a user_agent or anonymous_id that is not a string, a ts that is not an RFC 3339 date and time as above. |
forbidden_site | The key cannot write to the site: it does not exist, was deleted, belongs to another organization, or is outside the key's site list. |
ts_out_of_range | ts is more than 7 days ago or more than 5 minutes ahead. |
Errors for the whole request
| Status | When |
|---|---|
400 | The body is not JSON, or not { "events": [...] } with 1 to 500 elements (Expected { events: [1..500 events] }). |
401 | No API key, an invalid one, or another kind of credential. |
403 | The key lacks ingest:write. |
413 | The body is larger than 1 MiB. |
429 | The key has sent 60 write requests this minute: wait Retry-After seconds. |
A complete example
A subscription renews in your billing webhook, and you know the customer's user id; a second event is a purchase made during a request from the customer's browser, whose address and user agent your server saw.
curl -X POST "https://pilotfox.app/api/v1/ingest/events" \
-H "Authorization: Bearer $PILOTFOX_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"events": [
{
"site": "SITE_ID",
"type": "event",
"name": "subscription_renewed",
"anonymous_id": "customer_8412",
"uid": "user_8412",
"props": { "plan": "pro", "amount": 49, "annual": false },
"ts": "2026-10-27T18:05:00+02:00"
},
{
"site": "SITE_ID",
"type": "event",
"name": "order_paid",
"url": "https://example.com/checkout",
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"uid": "user_8412",
"props": { "order_id": "ord_5521", "total": 120 }
}
]
}
EOF{ "data": { "accepted": 2, "rejected": [] } }To send many events, collect them and send up to 500 per request, at most 60 requests a minute per key.