Ingest events from SDKs and server-side integrations using sdk.events.v1.EventsService/BatchCreate.
Auth: x-api-key header (public key pub_... or private key prv_...). No Authorization header or x-project-id is required - the project is resolved from the key. See Authentication.
BatchCreate
POST /sdk.events.v1.EventsService/BatchCreateValidates and enqueues up to 1 000 events in a single request. Returns the count of accepted events. There is no single-event endpoint - all ingestion goes through this method.
All google.protobuf.Timestamp fields (here, occurTime, and timestampValue inside a PropertyValue) serialize as RFC 3339 strings - e.g. "2026-06-05T10:00:00Z".
Event fields
| Proto field | JSON name | Type | Required | Notes |
|---|---|---|---|---|
event_id |
eventId |
string | Yes | UUID (any version; UUIDv7 recommended for time-sortability) |
auto_properties |
autoProperties |
map<string, PropertyValue> |
No | Context set by the SDK or server. Keys must start with $. $bot_score and $verified_bot are server-only - any client-supplied value is stripped and replaced from CDN headers |
custom_properties |
customProperties |
map<string, PropertyValue> |
No | Your event properties. Keys must not start with $ |
distinct_id |
distinctId |
string | Unless cookieless |
Anonymous or identified user ID. Must not start with cookieless- - that prefix is server-owned |
kind |
kind |
string | Yes | Event name, matching ^[a-zA-Z0-9_.-]+$. Must not start with pug. (reserved prefix) |
occur_time |
occurTime |
google.protobuf.Timestamp -> RFC 3339 |
Yes | When the event occurred |
session_id |
sessionId |
string | Unless cookieless |
UUID grouping events in a visit |
cookieless |
cookieless |
bool | No | Ask the server to derive identity instead of sending it. See Cookieless events |
Proto field names (snake_case) and lowerCamelCase are both accepted by the Connect JSON codec. The lowerCamelCase forms shown above are canonical.
Cookieless events
Set "cookieless": true when the client has stored no identifier - the Web SDK’s default consent state, and the shape to send from any client that hasn’t obtained consent. The server then derives an ephemeral, daily-rotating distinctId and stitches a sessionId (closed after 30 minutes of inactivity), so the traffic still counts while staying anonymous.
{
"eventId": "01966b9e-1234-7abc-abcd-0123456789ab",
"kind": "page_view",
"cookieless": true,
"occurTime": "2026-06-05T10:00:00Z",
"customProperties": {},
"autoProperties": {}
}The identity fields must be omitted entirely, not sent empty: a set sessionId has to satisfy the UUID rule, so "" is rejected. A batch may mix cookieless and identified events freely.
Derived IDs carry a cookieless- prefix, which is why a client may never send one. They are excluded from user-counting metrics by default, so a project running cookieless sees visitor counts without inflated user counts.
PropertyValue: typed property wrapper
Every value in customProperties and autoProperties is a wrapped object that names its type explicitly. This is the Connect JSON encoding of the protobuf oneof.
| Variant | JSON shape | Notes |
|---|---|---|
| String | { "stringValue": "hello" } |
Max 1 024 Unicode code points. Values exceeding the limit are rejected with invalid_argument (not truncated) |
| Integer | { "intValue": "42" } |
int64 - the value is a JSON string, not a number |
| Double | { "doubleValue": 3.14 } |
Must be finite (NaN and +/-Infinity are rejected) |
| Boolean | { "boolValue": true } |
|
| Timestamp | { "timestampValue": "2026-06-05T10:00:00Z" } |
RFC 3339; stored at millisecond precision |
Exactly one variant must be set per PropertyValue object. Proto snake_case aliases (e.g. string_value) are accepted as well.
Validation summary
All rules above enforced together; any violation fails the request with invalid_argument:
- Batch size: at most 1 000 events per
BatchCreatecall. eventId,sessionId: must be valid UUIDs.- Identity:
distinctIdandsessionIdare required unlesscookielessistrue, and must be omitted entirely when it is. cookieless-prefix: server-owned; a client-sentdistinctIdstarting with it is rejected (as is anexternalIdon Identify).$bot_score,$verified_bot: server-only; any client-supplied value is dropped and re-set from the CDN-injectedCF-Bot-Score/CF-Verified-Botheaders.
(Property-key prefix rules, stringValue length, and doubleValue finiteness are covered in the Event fields and PropertyValue tables above.)
Request / response shape
Property values are typed
PropertyValuewrappers. Every value incustomPropertiesandautoPropertiesnames its type explicitly:intValue(int64) must be a JSON string, doubles are bare numbers, etc. (see the table above). This is not how profile traits work: Identify’straitsare plain JSON, notPropertyValue-wrapped; see Profiles.
BatchCreateRequest
{
"events": [
{
"eventId": "<uuid>",
"distinctId": "anon-abc123",
"kind": "page_view",
"sessionId": "<uuid>",
"occurTime": "2026-06-05T10:00:00Z",
"customProperties": {
"plan": { "stringValue": "pro" },
"seats": { "intValue": "5" },
"mrr": { "doubleValue": 299.0 },
"trial": { "boolValue": false }
},
"autoProperties": {}
}
]
}BatchCreateResponse
{ "accepted": 1 }accepted (uint32) is the number of events that passed validation and were enqueued. It equals the number of events in the request when all events are valid.
Two more fields appear when the server refuses part of a batch while resolving cookieless identity - accepted plus dropped always equals the number sent:
{ "accepted": 8, "dropped": 2, "droppedByReason": { "salt_unavailable": 2 } }A partial drop is deliberately not an error: a batch may legitimately mix cookieless and identified events, so failing the whole request would discard healthy traffic to report a fault affecting part of it. That makes dropped the only signal that anything was lost - check it rather than assuming a 200 means everything landed.
| Reason | Cause | Retry the same payload? |
|---|---|---|
day_out_of_range |
occurTime fell outside the today/yesterday UTC salt window (usually severe client clock skew) |
No - it drops again |
salt_unavailable |
The daily salt could not be read or minted, so identity can’t be derived (and is never fabricated). Server-side | Yes |
salt_corrupt |
The day’s stored salt could not be decoded. Nothing overwrites it, so it re-rejects until the key expires | No - needs operator intervention |
A fourth reason, identity_headers_missing (no User-Agent, or no resolvable client address - usually a proxy stripping headers), rejects the whole request instead, so it surfaces as an error rather than in droppedByReason.
Errors
These are the codes BatchCreate returns in practice - a subset of the full
error model:
| Connect code | HTTP status | Cause |
|---|---|---|
unauthenticated |
401 | Missing or invalid x-api-key |
invalid_argument |
400 | Validation failed: see the message field for which rule |
resource_exhausted |
429 | Rate limit exceeded |
internal |
500 | Server error: retry with exponential backoff |
Error responses follow the Connect envelope:
{
"code": "invalid_argument",
"message": "event kind must not use reserved prefix 'pug.'"
}Further reading
- HTTP transport: full curl, Python, and Go examples for
BatchCreate - Auto-properties: the
$-prefixed property catalog set by the server - Authentication: public vs. private keys, key rotation