API Reference
Reference for the Vendo web tracking ingestion API: the /collect endpoint, event schema, authentication, and error codes.
Last reviewed September 15, 2026
The ingestion API receives events from the SDK and writes them to BigQuery. It runs as a Node.js Express service. Vendo’s managed deployment runs on Hetzner behind a Caddy reverse proxy. The public endpoints are https://track.vendodata.com (production) and https://track-staging.vendodata.com (staging).
Base URL
The API runs on the host configured for your workspace (for example, https://track.vendodata.com, or your own domain when self-hosting). All endpoints are relative to this base.
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /collect | Receive and process events |
POST | /admin/simulate | Validate, normalize, and preview a payload without writes |
GET | /health | Health check |
GET | /v1/sdk.js | Serve the JavaScript SDK |
GET | /v1/snippet | Generate a pre-filled HTML snippet |
POST /collect
The primary endpoint. Accepts a single event or a batch of events.
Authentication
Include the write key in one of these locations (checked in order):
| Method | Example |
|---|---|
X-Write-Key header | X-Write-Key: your-write-key |
Authorization header | Authorization: Bearer your-write-key |
| Body field | { "writeKey": "your-write-key", ... } |
The SDK sends the write key automatically, in the X-Write-Key header and in the request body.
Request: Single Event
{
"writeKey": "your-write-key",
"type": "track",
"messageId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-02-08T12:00:00.000Z",
"anonymousId": "anon-abc-123",
"event": "Product Viewed",
"properties": {
"sku": "SKU-1",
"price": 29.99
},
"context": {
"source": "web",
"session": { "id": "session-123" },
"page": { "url": "https://example.com/products/sku-1" },
"campaign": { "utm_source": "google" },
"clickId": { "gclid": "..." }
}
}Request: Batch
{
"writeKey": "your-write-key",
"sentAt": "2026-02-08T12:00:05.000Z",
"batch": [
{
"type": "page",
"messageId": "msg-1",
"timestamp": "2026-02-08T12:00:00.000Z",
"anonymousId": "anon-abc-123",
"properties": { "page_url": "https://example.com/pricing" }
},
{
"type": "track",
"messageId": "msg-2",
"timestamp": "2026-02-08T12:00:03.000Z",
"anonymousId": "anon-abc-123",
"event": "Button Clicked",
"properties": { "label": "signup" }
}
]
}Maximum batch size: 100 events per request. This is the server-side hard limit. The API rejects a request with more than 100 events with 413 batch_too_large. This limit is independent of, and larger than, the SDK’s default batchSize of 20 events per batch. The SDK flushes well below the API limit, and you can raise its batchSize up to 100.
Response: Success
{
"ok": true,
"received": 2,
"invalid": 0,
"errors": [],
"accepted": true,
"batchId": "batch-123",
"deliveryCount": 1,
"deduplicated": false
}In Supabase auth mode (the managed deployment), the API returns 202 after it commits the batch to its durable outbox. In static auth mode, the API returns 200 with an inserted object that has the result for each destination. That object replaces accepted, batchId, deliveryCount, and deduplicated.
Response: Partial Success
If some events are invalid, valid events are still processed:
{
"ok": true,
"received": 1,
"invalid": 1,
"errors": [
{ "index": 1, "errors": [{ "message": "must have required property 'type'" }] }
],
"accepted": true,
...
}Event Schema
Required Fields (All Events)
| Field | Type | Description |
|---|---|---|
type | string | Event type: track, identify, page, group, or alias |
messageId | string | Unique event ID (UUID recommended) |
timestamp | string | ISO 8601 timestamp |
anonymousId | string | Anonymous visitor ID |
Conditional Fields
| Event Type | Required Fields |
|---|---|
track | event (event name) |
identify | userId |
group | groupId |
alias | userId, previousId |
page | None additional |
Optional Fields
| Field | Type | Description |
|---|---|---|
userId | string | Known user ID (set after identify) |
sessionId | string | Session identifier |
writeKey | string | Write key (alternative to header auth) |
properties | object | Event properties (for track and page) |
traits | object | User/group traits (for identify and group) |
context | object | Shared metadata envelope such as page, session, campaign, click ID, device, locale, and consent |
sentAt | string | Client-side send timestamp (for clock correction) |
The context Envelope
Every event carries a structured context object. The SDK populates it automatically. You can merge additional keys with the context argument on track(), page(), identify(), group(), and alias(). The API normalizes these keys into typed warehouse columns.
| Key | Type | Description |
|---|---|---|
source | string | Always "web" for the browser SDK. |
library | object | SDK identity: { name: "vendo-web-tracking", version }. |
session | object | { id }: the current session ID. |
page | object | { url, path, title, referrer } for the current page. |
screen | object | { width, height } in pixels. |
campaign | object | UTM parameters captured from the URL (last-touch), for example { utm_source, utm_medium }. |
clickId | object | Advertising click IDs captured from the URL (last-touch), for example { gclid, fbclid }. |
consent | object | Consent groups granted or denied at send time, for example { analytics: true, marketing: false }. |
browser | string | Browser name. |
os | string | Operating system. |
device | string | Device / platform. |
language | string | Browser language (for example, "en-US"). |
locale | string | Browser locale. |
userAgent | string | Full user-agent string. |
The key names are camelCase (campaign, clickId, userAgent, consent) as emitted by the SDK.
Table Routing
| Event Type | BigQuery Table |
|---|---|
track | events |
page | events |
identify | users |
group | groups |
alias | aliases |
Server-Added Fields
The ingestion API enriches each event with:
| Field | Description |
|---|---|
received_at | Server timestamp when the event was received |
context_ip | Client IP (from X-Forwarded-For or request IP) |
context_user_agent | Client user agent string |
POST /admin/simulate
Dry-run an event payload through validation, normalization, and destination preview without writing rows or sending events to downstream destinations.
This endpoint is intended for demos, setup flows, and internal support tooling. It only works when all of these are true:
DEMO_MODE=trueADMIN_SETUP_ENABLEDis enabled- A matching
ADMIN_SETUP_TOKENis sent in theX-Admin-Tokenheader
curl -X POST https://track.yourdomain.com/admin/simulate \
-H "Content-Type: application/json" \
-H "X-Admin-Token: $ADMIN_SETUP_TOKEN" \
-d '{
"writeKey": "YOUR_WRITE_KEY",
"batch": [
{
"type": "track",
"messageId": "debug-1",
"timestamp": "2026-04-30T00:00:00.000Z",
"anonymousId": "anon-123",
"event": "Product Viewed",
"properties": { "sku": "SKU-1" },
"context": {
"page": { "url": "https://example.com/products/sku-1" },
"campaign": { "utm_source": "google" }
}
}
]
}'The response includes:
| Field | Description |
|---|---|
simulated | Always true for this endpoint |
rows | Normalized event/user/group/alias rows that would be written |
destinations | Preview of rows after destination mapping and consent filtering |
warnings | Dry-run caveats, including skipped writes, rules, schema discovery, and live events |
merchant | Resolved tenant details when a write key maps to a tenant |
No BigQuery inserts, live event publishes, schema discovery, rules engine execution, or destination sends happen during simulation.
Error Responses
| Status | Error Code | Description |
|---|---|---|
400 | invalid_payload | Request body is missing or not an object |
400 | invalid_batch | Batch array failed schema validation |
400 | no_valid_events | No events in the batch passed validation |
401 | missing_write_key | No write key found in header or body |
401 | invalid_write_key | Write key does not match |
403 | origin_not_allowed | The write key has allowed origins, and the request Origin header is missing or does not match one of them or its subdomains |
413 | batch_too_large | Batch exceeds 100 events |
429 | rate_limit_exceeded | The request is over a rate limit. See Rate Limits |
500 | server_error | Internal server error |
503 | auth_unavailable | Supabase auth mode: the API cannot check the write key at this time |
503 | durable_ingress_unavailable | Supabase auth mode: the write key has no enabled destination, or the API cannot commit the batch to its outbox. The reason field shows which |
503 | delivery_unavailable | Static auth mode: no destination is ready, or a destination did not receive all rows. The reason field shows which |
Responses with status 429 or 503 include a retryAfter field, in seconds. Some also send a Retry-After header. The SDK retries these responses.
Error responses from the API follow this format. Only some errors include details:
{
"ok": false,
"error": "error_code",
"details": []
}GET /health
Returns { "ok": true, "durableIngest": false } when durable ingest is off. In Supabase auth mode, durable ingest is on and the response also includes deliveryHealthy and delivery. If the API cannot read the outbox, it returns 503 with "ok": false. Use this endpoint for load balancer health checks.
GET /v1/sdk.js
Serves the compiled JavaScript SDK as application/javascript with a 1-hour cache header. The API loads the SDK file into memory at startup.
Note: The SDK is also available via Vendo’s CDN at
https://cdn.vendodata.com/sdk/v1/vendo.js. The CDN-hosted version is identical to the one served by the ingestion API. Use the CDN for the fastest setup, or serve from your own domain for maximum first-party control.
Set the SDK_JS_PATH environment variable to override the default SDK file location.
GET /v1/snippet
Returns a ready-to-paste HTML <script> block with the write key and host pre-filled.
| Query Param | Default | Description |
|---|---|---|
writeKey | YOUR_WRITE_KEY | Write key to embed in the snippet |
host | Auto-detected from request | Tracking host URL |
Example: GET /v1/snippet?writeKey=my-key
Known issue: Before the SDK loads, this snippet does not have
setConsent,getConsentordebugState. Call these methods only after the SDK loads.
Authentication Modes
The API has exactly two auth modes:
| Mode | Config | Use Case |
|---|---|---|
| Static | WRITE_KEY env var | Single-tenant, local dev, demos |
| Supabase | SUPABASE_AUTH_ENABLED=true | Multi-tenant (production deployment) |
In static mode, the write key in the request must match the WRITE_KEY environment variable.
In Supabase mode, the API resolves the write key through Supabase, which is the only write-key control plane. The resolved tenant record carries the merchant’s destination configuration (Mixpanel, Segment, Customer.io, OneSignal, BigQuery). The former firebase and dual modes are retired. The API no longer reads Firestore.
Rate Limits
In static auth mode, the API does not enforce rate limits. In Supabase auth mode, the API applies these limits:
- Each write key on each server instance: 500 events per second. This limit applies with or without Redis.
- Each write key, with Redis configured: The default limits are 100 events per second and 100,000 events per day. Without Redis, the API does not apply these limits.
- Write-key checks from each IP address: 60 each minute. This limit counts only write keys that are not in the API cache of valid keys. The response includes a
Retry-After: 60header.
If a request is over a limit, the API returns 429 rate_limit_exceeded. The limitType field shows which limit: second, day or auth_resolution. The API also rejects a request body larger than 2 MB.
On a self-hosted API, RATE_LIMIT_LOCAL_BACKSTOP_EPS changes the limit of 500 events per second. AUTH_RESOLUTION_ATTEMPTS_PER_MINUTE changes the limit of 60 write-key checks.
For production deployments, configure rate limiting at the load balancer or CDN level. Recommended limits:
| Scope | Limit |
|---|---|
| Per IP | 100 requests/second |
| Per write key | 1000 events/second |
| Request body | 2 MB max |
Related
- JavaScript SDK: Client-side SDK reference
- Self-Hosting: Deploy the ingestion API