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

# Track a batch of events

POST /batch
Content-Type: application/json

Records multiple events in one request and returns a collector response
for each, in order. At most ``MAX_BATCH_EVENTS`` events per request.

Reference: https://docs.pivolio.com/api-reference/pivolio/event-ingestion/batch-events

## Authentication

- `X-Write-Key` header (required) — API Key authentication via header

## Request

### Body (application/json)

This endpoint expects a list of UnifiedEvent.

- `list of UnifiedEvent`

## Response

### 200

Successful Response

- `list of CollectorResponse`

## Errors

### 422 Unprocessable Entity Error

Validation Error

- `detail` (string, required)
- `code` (string, required)
- `errors` (list of FieldError, optional, nullable)

## Types

### UnifiedEvent

The event envelope every SDK and integration sends. Grouped one level deep and no further. Each group is a coherent set of fields that travel together (``page``, ``utm``, ``click_ids``, …), which keeps the payload readable while still mapping one hop to a flat analytics column. Nothing is repeated: a value appears in exactly one group.

- `event_id` (string, required) — Unique event ID (ULID)
- `event_name` (string, required) — Event name (page_view, order_completed, etc.)
- `event_time` (double, required) — Event timestamp (Unix seconds)
- `identity` (EventIdentity, required) — Who the event belongs to
- `schema_version` ("1", optional, default: 1) — Schema version
- `test_mode` (boolean, optional, default: false) — Whether event is in test mode
- `traits` (Traits, optional, nullable) — User traits (email, phone, etc.)
- `page` (PageInfo, optional) — Page information.
- `utm` (UtmParams, optional) — Campaign attribution parameters, as captured from the landing URL.
- `click_ids` (ClickIds, optional) — Click IDs from ad platforms.
- `client` (ClientInfo, optional) — The requesting client's environment. ``ip`` and ``user_agent`` are stamped server-side from the connection: a client-supplied address would defeat geo, velocity and bot scoring.
- `device` (DeviceInfo, optional) — Device information.
- `consent` (Consent, optional) — Google Consent Mode v2. A category is ``granted``, ``denied``, or absent — and absent is a real third state that ``ConsentWhenAbsent`` decides, not a synonym for either answer. Semantics live in ``pivolio.consent``.
- `signals` (RequestSignals, optional) — Header-derived signals captured at the HTTP edge for bot scoring. None means the header was absent.
- `source` (EventSource, optional) — How this event reached us, and from which installation. Replaces the free-form ``webhook_source``: the kind is a closed vocabulary so ingestion can be reported on, and ``integration_id`` answers *which* store or receiver when a workspace has more than one.
- `session` (SessionInfo, optional) — Client-side session the event belongs to (rotated after inactivity). Empty for server-side events, which have no session. A start time without an id is rejected — that would be a session that does not exist but began.
- `library` (LibraryInfo, optional) — SDK library metadata. Empty when the event did not come from an SDK. Half-filled is rejected rather than made unrepresentable: the group has to be constructible empty so it can always be present, so the "both or neither" invariant moves from the type to a validator instead of being lost.
- `app` (AppInfo, optional) — Mobile app information.
- `properties` (map from string to any, optional, nullable) — Event properties

### CollectorResponse

Response from the collector endpoint.

- `ok` (boolean, optional, default: true)
- `event_id` (string, optional, nullable)
- `duplicate` (boolean, optional, default: false)

### FieldError

- `field` (string, required)
- `message` (string, required)

### EventIdentity

Who the event belongs to, as the caller knows them. All three are opaque identifiers, not PII — the PII the caller sends lives in ``traits``. ``user_id`` and ``external_id`` are near-twins that differ by direction: ``user_id`` is *inbound*, stitching a person across sessions and devices (it is the only field that marks one identified), while ``external_id`` is *outbound*, shipped to the ad platforms as their match key. They are usually the same value, and a caller who does not want its internal user id sent to Meta supplies a pseudonymous ``external_id`` instead.

- `anonymous_id` (string, required) — Anonymous visitor ID
- `user_id` (string, optional, nullable) — Known user ID after identify
- `external_id` (string, optional, nullable) — Stable user key sent to ad platforms as their match id

### Traits

User attributes carried on an event (raw PII, hashed server-side before any delivery). Well-known match keys are typed; any extra custom attributes are preserved (`extra="allow"`) and merged onto the person record. PII only — opaque identifiers belong in ``identity``, which is what keeps this bag exactly the set of values that get hashed and excluded from ClickHouse.

- `email` (string, optional, nullable)
- `phone` (string, optional, nullable)
- `first_name` (string, optional, nullable)
- `last_name` (string, optional, nullable)
- `country` (string, optional, nullable)
- `city` (string, optional, nullable)
- `state` (string, optional, nullable)
- `zip` (string, optional, nullable)
- `gender` (string, optional, nullable)
- `date_of_birth` (string, optional, nullable)

### PageInfo

Page information.

- `url` (string, optional, nullable)
- `path` (string, optional, nullable)
- `referrer` (string, optional, nullable)
- `title` (string, optional, nullable)

### UtmParams

Campaign attribution parameters, as captured from the landing URL.

- `source` (string, optional, nullable)
- `medium` (string, optional, nullable)
- `campaign` (string, optional, nullable)
- `term` (string, optional, nullable)
- `content` (string, optional, nullable)

### ClickIds

Click IDs from ad platforms.

- `fbclid` (string, optional, nullable)
- `fbp` (string, optional, nullable)
- `fbc` (string, optional, nullable)
- `gclid` (string, optional, nullable)
- `gbraid` (string, optional, nullable)
- `wbraid` (string, optional, nullable)
- `ttclid` (string, optional, nullable)
- `ttp` (string, optional, nullable)
- `li_fat_id` (string, optional, nullable)
- `msclkid` (string, optional, nullable)
- `epik` (string, optional, nullable)
- `rdt_cid` (string, optional, nullable)
- `sc_click_id` (string, optional, nullable)
- `twclid` (string, optional, nullable)

### ClientInfo

The requesting client's environment. ``ip`` and ``user_agent`` are stamped server-side from the connection: a client-supplied address would defeat geo, velocity and bot scoring.

- `ip` (string, optional, nullable) — Client IP (set by server)
- `user_agent` (string, optional, nullable) — User agent (set by server)
- `locale` (string, optional, nullable)
- `timezone` (string, optional, nullable)

### DeviceInfo

Device information.

- `type` (enum, optional, nullable) — Device classification.
  - Allowed values: `mobile`, `tablet`, `desktop`, `bot`, `unknown`
- `os` (string, optional, nullable)
- `os_version` (string, optional, nullable)
- `browser` (string, optional, nullable)
- `browser_version` (string, optional, nullable)

### Consent

Google Consent Mode v2. A category is ``granted``, ``denied``, or absent — and absent is a real third state that ``ConsentWhenAbsent`` decides, not a synonym for either answer. Semantics live in ``pivolio.consent``.

- `ad_storage` (enum, optional, nullable) — GDPR/Privacy consent states.
  - Allowed values: `granted`, `denied`
- `analytics_storage` (enum, optional, nullable) — GDPR/Privacy consent states.
  - Allowed values: `granted`, `denied`
- `ad_user_data` (enum, optional, nullable) — GDPR/Privacy consent states.
  - Allowed values: `granted`, `denied`
- `ad_personalization` (enum, optional, nullable) — GDPR/Privacy consent states.
  - Allowed values: `granted`, `denied`

### RequestSignals

Header-derived signals captured at the HTTP edge for bot scoring. None means the header was absent.

- `accept_language` (string, optional, nullable)
- `sec_ch_ua` (string, optional, nullable)
- `sec_ch_ua_platform` (string, optional, nullable)
- `sec_fetch_site` (string, optional, nullable)
- `sec_fetch_mode` (string, optional, nullable)

### EventSource

How this event reached us, and from which installation. Replaces the free-form ``webhook_source``: the kind is a closed vocabulary so ingestion can be reported on, and ``integration_id`` answers *which* store or receiver when a workspace has more than one.

- `kind` (enum, optional) — Where an event came from — the SDKs, the pixel, or an inbound integration. Every event carries one, so ingestion volume is reportable by channel rather than inferred. ``SDK`` and ``PIXEL`` are our own collectors; the rest are integrations a workspace installs, each authenticating with its own write key. Stripe/HubSpot and friends will be added as they get their own endpoints. Only the installable values ever appear on an ``Integration`` record — ``SDK`` and ``PIXEL`` are event sources with nothing to install.
  - Allowed values: `sdk`, `pixel`, `webhook`, `shopify`
- `integration_id` (string, optional, nullable)

### SessionInfo

Client-side session the event belongs to (rotated after inactivity). Empty for server-side events, which have no session. A start time without an id is rejected — that would be a session that does not exist but began.

- `id` (string, optional, nullable)
- `started_at` (double, optional, nullable) — Session start time (Unix seconds)

### LibraryInfo

SDK library metadata. Empty when the event did not come from an SDK. Half-filled is rejected rather than made unrepresentable: the group has to be constructible empty so it can always be present, so the "both or neither" invariant moves from the type to a validator instead of being lost.

- `name` (string, optional, nullable)
- `version` (string, optional, nullable)

### AppInfo

Mobile app information.

- `id` (string, optional, nullable)
- `version` (string, optional, nullable)
- `platform` (enum, optional, nullable) — Mobile app platform (for events sent from a native app).
  - Allowed values: `ios`, `android`

## Examples

**Request**

```json
[
  {
    "event_id": "01F8MECHZX3TBDSZ7XRADM79XE",
    "event_name": "order_completed",
    "event_time": 1686806400,
    "anonymous_id": "anon-7f4e2c9b"
  }
]
```

**Response**

```json
[
  {
    "ok": true,
    "event_id": "01F8MECHZX3TBDSZ7XRADM79XE",
    "duplicate": false
  }
]
```

**SDK Code**

```python
import requests

url = "https://api.example.com/batch"

payload = [
    {
        "event_id": "01F8MECHZX3TBDSZ7XRADM79XE",
        "event_name": "order_completed",
        "event_time": 1686806400,
        "anonymous_id": "anon-7f4e2c9b"
    }
]
headers = {
    "X-Write-Key": "<apiKey>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript
const url = 'https://api.example.com/batch';
const options = {
  method: 'POST',
  headers: {'X-Write-Key': '<apiKey>', 'Content-Type': 'application/json'},
  body: '[{"event_id":"01F8MECHZX3TBDSZ7XRADM79XE","event_name":"order_completed","event_time":1686806400,"anonymous_id":"anon-7f4e2c9b"}]'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api.example.com/batch"

	payload := strings.NewReader("[\n  {\n    \"event_id\": \"01F8MECHZX3TBDSZ7XRADM79XE\",\n    \"event_name\": \"order_completed\",\n    \"event_time\": 1686806400,\n    \"anonymous_id\": \"anon-7f4e2c9b\"\n  }\n]")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("X-Write-Key", "<apiKey>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby
require 'uri'
require 'net/http'

url = URI("https://api.example.com/batch")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["X-Write-Key"] = '<apiKey>'
request["Content-Type"] = 'application/json'
request.body = "[\n  {\n    \"event_id\": \"01F8MECHZX3TBDSZ7XRADM79XE\",\n    \"event_name\": \"order_completed\",\n    \"event_time\": 1686806400,\n    \"anonymous_id\": \"anon-7f4e2c9b\"\n  }\n]"

response = http.request(request)
puts response.read_body
```

```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api.example.com/batch")
  .header("X-Write-Key", "<apiKey>")
  .header("Content-Type", "application/json")
  .body("[\n  {\n    \"event_id\": \"01F8MECHZX3TBDSZ7XRADM79XE\",\n    \"event_name\": \"order_completed\",\n    \"event_time\": 1686806400,\n    \"anonymous_id\": \"anon-7f4e2c9b\"\n  }\n]")
  .asString();
```

```php
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://api.example.com/batch', [
  'body' => '[
  {
    "event_id": "01F8MECHZX3TBDSZ7XRADM79XE",
    "event_name": "order_completed",
    "event_time": 1686806400,
    "anonymous_id": "anon-7f4e2c9b"
  }
]',
  'headers' => [
    'Content-Type' => 'application/json',
    'X-Write-Key' => '<apiKey>',
  ],
]);

echo $response->getBody();
```

```csharp
using RestSharp;

var client = new RestClient("https://api.example.com/batch");
var request = new RestRequest(Method.POST);
request.AddHeader("X-Write-Key", "<apiKey>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "[\n  {\n    \"event_id\": \"01F8MECHZX3TBDSZ7XRADM79XE\",\n    \"event_name\": \"order_completed\",\n    \"event_time\": 1686806400,\n    \"anonymous_id\": \"anon-7f4e2c9b\"\n  }\n]", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift
import Foundation

let headers = [
  "X-Write-Key": "<apiKey>",
  "Content-Type": "application/json"
]
let parameters = [
  [
    "event_id": "01F8MECHZX3TBDSZ7XRADM79XE",
    "event_name": "order_completed",
    "event_time": 1686806400,
    "anonymous_id": "anon-7f4e2c9b"
  ]
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api.example.com/batch")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```