Sending events over HTTP

Ingest events from a server, a CDN worker, or anything that speaks 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.

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
stringRequired

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

event_name
stringRequired

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

event_time
numberRequired

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
stringRequired

The device or visitor identifier. Call /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").

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.

$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:

1{ "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.

$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.

$curl "https://api.pivolio.com/id" -H "X-Write-Key: wk_live_xxxxxxxxxxxx"
1{ "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.

$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.

/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:

1{ "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 for the full error envelope.