> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.pivolio.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pivolio.com/_mcp/server.

# Sending events over HTTP

The browser SDKs are a convenience wrapper around four plain HTTP endpoints. If
you're tracking from a backend, a queue consumer, or a language with no Pivolio
SDK, call them directly.

Every ingestion endpoint authenticates with a workspace **write key** in the
`X-Write-Key` header — see [Authentication](/documentation/guides/authentication).

## The four required fields

An event body is a `UnifiedEvent`. Four fields are required on every single
event, and omitting any one of them returns `422`:

**`event_id`** `string` — required

A unique ID for this event, used for deduplication. The SDKs generate ULIDs
(26 characters, time-sortable), which is a good default.

---

**`event_name`** `string` — required

Must match `^[a-zA-Z0-9_.]{1,128}$`. Prefer a
[standard event name](/documentation/core-concepts/events) — custom names still record and
report, but destinations only map the standard ones.

---

**`event_time`** `number` — required

Unix time in **seconds** (a float is fine). Milliseconds is the most common
mistake here — a millisecond timestamp puts your event roughly 50,000 years
into the future and destinations will reject it.

---

**`anonymous_id`** `string` — required

The device or visitor identifier. Call [`/id`](#get-id) to get a
server-issued one if you don't already have it.

---

Everything else is optional: `user_id`, `traits`, `context`, `consent`,
`properties`, `test_mode`, `schema_version` (defaults to `"1"`).

> **Note**
>
> `context.ip` is set by the collector from the connecting socket. A value you
> supply is always overwritten — proxy the request from the client's IP if you
> need real geo resolution.

## POST /track

Sends exactly one event.

```bash
curl https://api.pivolio.com/track \
  -H "X-Write-Key: wk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "01JQ8Z3M9V4K7C2N6P8R5T1W0X",
    "event_name": "order_completed",
    "event_time": 1750000000,
    "anonymous_id": "anon_9f2c1d84-3b7a-4e56-8c10-2ad4f9b7e103",
    "user_id": "user_456",
    "traits": { "email": "jane@acme.com", "phone": "+14155550123" },
    "properties": { "order_id": "ord_123", "revenue": 99.5, "currency": "USD" }
  }'
```

Send raw email and phone values. Pivolio normalizes and hashes them server-side
so every destination gets byte-identical digests — hashing them yourself is the
fastest way to quietly destroy your match rates.

Every response has the same shape:

```json
{ "ok": true, "event_id": "01JQ8Z3M9V4K7C2N6P8R5T1W0X", "duplicate": false }
```

## POST /batch

Takes an array of the same objects, **maximum 500 per request**. Use it when
you're replaying a backlog or flushing a buffer — one connection instead of 500.

```bash
curl https://api.pivolio.com/batch \
  -H "X-Write-Key: wk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "event_id": "01JQ8Z3M9V4K7C2N6P8R5T1W0X",
      "event_name": "view_content",
      "event_time": 1750000000,
      "anonymous_id": "anon_9f2c1d84-3b7a-4e56-8c10-2ad4f9b7e103"
    },
    {
      "event_id": "01JQ8Z4P2H6D9F3G7J1L5N8Q0S",
      "event_name": "add_to_cart",
      "event_time": 1750000042,
      "anonymous_id": "anon_9f2c1d84-3b7a-4e56-8c10-2ad4f9b7e103",
      "properties": { "value": 24.99, "currency": "USD" }
    }
  ]'
```

## GET /id

Returns a server-issued anonymous ID and sets it as an httpOnly `_at_aid`
cookie with a 365-day lifetime.

```bash
curl "https://api.pivolio.com/id" -H "X-Write-Key: wk_live_xxxxxxxxxxxx"
```

```json
{ "anonymous_id": "anon_9f2c1d84-3b7a-4e56-8c10-2ad4f9b7e103" }
```

The reason this endpoint exists: Safari's tracking prevention caps
script-written cookies at about seven days, so a browser-generated ID quietly
resets and splits one visitor into many. A server-set httpOnly cookie survives
far longer.

## GET /pixel

For contexts that can't run JavaScript or issue a POST — an email open, an AMP
page, a third-party template. Pass the write key as `wk` in the query string,
or in the header as usual.

```bash
curl "https://api.pivolio.com/pixel?wk=wk_live_xxxxxxxxxxxx\
&event_id=01JQ8Z3M9V4K7C2N6P8R5T1W0X\
&event_name=page_view\
&event_time=1750000000\
&anonymous_id=anon_9f2c1d84-3b7a-4e56-8c10-2ad4f9b7e103\
&format=img"
```

With `format=img` the response is a 1×1 transparent GIF, so the URL can go
straight into an `<img>` tag. Without it you get the same JSON envelope as
`/track`.

> **Warning**
>
> **`/pixel` accepts no PII.** Traits like email and phone are not read from the
> query string — a URL travels through referrer headers, proxy logs and CDN
> caches, so it's the wrong place for them. Any conversion that carries PII must
> go through `/track` or `/batch`.

## Deduplication

Idempotency is keyed on `(workspace, event_id)`. Re-sending an event you already
delivered is safe — it is recorded once, and the response tells you what
happened:

```json
{ "ok": true, "event_id": "01JQ8Z3M9V4K7C2N6P8R5T1W0X", "duplicate": true }
```

This is what makes retries safe. If a request times out, resend it with the
same `event_id` rather than minting a new one.

## Rate limits

There are two independent budgets, and they count different things:

* **Per IP** — counts *requests*. A batch of 500 events is one request.
* **Per workspace** — counts *events*. A batch of 500 events is 500.

Exceeding either returns `429` with a `detail` of
`"Rate limit exceeded (IP)"` or `"Rate limit exceeded (workspace)"`, so you can
tell which one you hit. Batching is the cheapest way to stay under the IP
budget; it does nothing for the workspace budget.

See [Errors](/documentation/guides/errors) for the full error envelope.