Sending events
Collect user behavior events with POST /api/v1/t — and know exactly what the response does and does not guarantee.
Retidal collects user behavior through a single endpoint: POST /api/v1/t. Every
event you send — page views, signups, purchases — flows through this route, gets
validated, and (depending on how you call it) is either dispatched to background
processing or written synchronously before you get a response. Understanding which
mode you’re in, and what each response actually proves, is the single most
important thing on this page.
Prerequisites
Section titled “Prerequisites”- A Retidal project and API key — see Authentication.
Endpoint
Section titled “Endpoint”POST https://api.retidal.com/api/v1/tAuthenticate with the X-API-Key header. If you’re calling from a context that
can’t set headers — navigator.sendBeacon() — pass the key as a query parameter
instead: ?_ak=<key> or ?key=<key>. The header takes priority when more than one
is present.
Wrong domain is a known mistake
Send events to https://api.retidal.com, never to the console domain
(https://retidal.com). The console domain serves the dashboard and the Management
API, not ingestion.
Request body
Section titled “Request body”Send a single event object, a bare array, or a { "events": [...] } wrapper. A
single request to POST /api/v1/t accepts at most 100 events — a larger batch
is rejected with 400.
{ "eventName": "user_paid", "visitorId": "vid_abc123", "userId": "uid_456", "sessionId": "sess_789", "properties": { "amount": 9900, "currency": "CNY" }, "clickIds": { "bd_vid": "abc", "gclid": "xyz" }}[ { "eventName": "page_view", "visitorId": "vid_abc123" }, { "eventName": "user_paid", "visitorId": "vid_abc123", "properties": { "amount": 4900 } }]{ "events": [ { "eventName": "page_view", "visitorId": "vid_abc123" }, { "eventName": "user_paid", "visitorId": "vid_abc123", "properties": { "amount": 4900 } } ]}Fields
Section titled “Fields”Every field below accepts both camelCase and snake_case (for example
visitorId / visitor_id).
eventNamebodystringrequiredEvent name, matched against ^[a-z0-9_]{1,100}$. Aliases: event, event_name.
A non-matching name is a warn-level error but is still processed; a missing
eventName marks the whole event invalid and it is dropped.
visitorIdbodystringDevice/browser-level anonymous ID. Alias visitor_id. Defaults to "anon" when
omitted — generate and persist a real UUID per device instead, or attribution
degrades badly.
userIdbodystringLogged-in user ID. Alias user_id.
sessionIdbodystringSession-scoped ID. Alias session_id. This is your Tier-1 attribution key — see
Cross-device attribution.
propertiesbodyobjectFree-form key/value payload, roughly 32 KB serialized before you hit a
warn-level properties_too_large. Put monetary values in amount (or value),
in minor units — 9900 means ¥99.00. currency defaults to CNY.
eventIdbodystringClient-supplied idempotency key, matching ^[A-Za-z0-9_.-]{8,64}$. Alias
event_id. A key that doesn’t match the pattern is ignored (with a warning) and
the server generates one instead. Required for metering (conversion) events
sent through the backfill endpoint.
clientTimestampbodystringISO 8601 event time. Aliases client_timestamp, timestamp. Unparseable values
are a warn-level error.
shortLinkIdbodystringShort-link record ID, read from the _tl query parameter after a short-link
redirect. Alias short_link_id. See Short links.
shortLinkCodebodystringShort code for direct-landing attribution (no redirect involved). Aliases _tk,
short_link_code.
clickIdsbodyobjectUnified ad-platform Click ID map, e.g. { "bd_vid": "...", "gclid": "..." } —
covers all 29 supported platforms. This is the recommended way to pass Click IDs;
see Click IDs for the full field list and the
legacy top-level alternative.
The response contract
Section titled “The response contract”This is the part to get right. POST /api/v1/t has two response modes, and they
mean different things.
Default: asynchronous (POST /api/v1/t returns 202)
Section titled “Default: asynchronous (POST /api/v1/t returns 202)”{ "accepted": 2, "failed": 0, "queued": 2, "processed": false }accepted— events that passed validation and were dispatched into background processing.failed— events dropped at the envelope layer (missingeventName, unparseable JSON for that item, etc).queued— metering events enqueued to the metering ingest queue.processed— alwaysfalseon this path.
202 is not proof of persistence
accepted >= 1 only proves the event passed validation and was handed to
ctx.waitUntil for background processing. It does not prove the event
reached D1 or Analytics Engine — that write happens after the response, and a
downstream failure can still lose it. Treating POST /api/v1/t’s 202 as “done”
was the root cause of a real 2026-05-10 data-loss incident. Use the sync mode
below to verify a specific event actually landed.
When any events in the batch are invalid, the response adds an invalid array
with a reason and a truncated rawSample per item.
Synchronous (?sync=1 or X-Trackly-Sync: 1): POST /api/v1/t returns 200
Section titled “Synchronous (?sync=1 or X-Trackly-Sync: 1): POST /api/v1/t returns 200”curl -X POST "https://api.retidal.com/api/v1/t?sync=1" \ -H "X-API-Key: your-api-key" \ -H "Content-Type: application/json" \ -d '{"eventName":"healthcheck","visitorId":"vid_test"}'{ "accepted": 1, "failed": 0, "queued": 0, "processed": true }processed: true is the actual evidence that non-metering events reached D1 and
Analytics Engine before the response was sent. Use this mode for integration
testing and health checks — not for production traffic, since it trades latency
for that guarantee. It also surfaces:
errors— per-event validation errors (present only when non-empty).enforceRejected— count of events rejected by enforce-mode validation.
Validation modes
Section titled “Validation modes”POST /api/v1/t resolves a validation mode from the API key or the project’s
settings (key-level setting wins, then falls back to
settings.ingestValidation.mode, defaulting to warn):
| Mode | Behavior |
|---|---|
off |
No schema validation at all. |
warn (default) |
Validated, but violations don’t block ingestion — surfaced via sync=1 errors[] and DebugView. |
enforce |
Events that fail property-schema validation are rejected. Envelope-level warnings (like a missing visitorId) never trigger enforce rejection — only schema violations do. |
If every event in a POST /api/v1/t sync=1 request is rejected under enforce
mode, the whole request comes back as 422 with the same shape as the POST /api/v1/t 200 body, enforceRejected set, and per-event errors:
{ "accepted": 0, "failed": 1, "queued": 0, "processed": true, "enforceRejected": 1, "errors": [ { "index": 0, "eventName": "user_paid", "errors": [ { "field": "amount", "rule": "type", "expected": "number", "actual": "string", "message": "Field amount: expected number, got string" } ] } ]}Error responses
Section titled “Error responses”| Status | Meaning |
|---|---|
400 |
Invalid JSON body, or the batch exceeds 100 events (POST /api/v1/t). |
401 |
Missing or invalid X-API-Key (POST /api/v1/t). |
422 |
Enforce mode rejected every event in the batch (sync path only, POST /api/v1/t). |
503 |
Transient infrastructure failure (POST /api/v1/t) — see below. |
POST /api/v1/t’s 503 carries { "error": "SELECTOR_LOOKUP_FAILED", "retryAfter": 30 } when the
metering-selector lookup fails, or { "error": "QUEUE_UNAVAILABLE", "retryAfter": 60 }
when metering events are present but the queue binding is missing or the enqueue
failed. Retry after the given number of seconds.
Batch backfilling historical events
Section titled “Batch backfilling historical events”POST /internal/backfill/events accepts the same three body shapes, processes
synchronously, and always returns processed: true — but the batch cap is 1000
instead of 100. Metering (conversion) events in a backfill batch must carry a
client-supplied eventId; if any don’t, the whole request fails with 400:
{ "error": "MISSING_EVENT_ID", "affectedIndices": [0, 3], "message": "计量级事件 backfill 必须提供 eventId"}Sending code samples
Section titled “Sending code samples”async function trackEvent(eventName, visitorId, properties = {}) { const response = await fetch("https://api.retidal.com/api/v1/t", { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": "your-api-key", }, body: JSON.stringify({ eventName, visitorId, properties, clientTimestamp: new Date().toISOString(), url: window.location.href, referrer: document.referrer, }), }); return response.json();}
await trackEvent("user_paid", "vid_abc123", { amount: 9900, currency: "CNY" });import requests, time
RETIDAL_URL = "https://api.retidal.com"API_KEY = "your-api-key"
def track(event_name, visitor_id, user_id=None, properties=None): resp = requests.post(f"{RETIDAL_URL}/api/v1/t", json={ "events": [{ "eventName": event_name, "visitorId": visitor_id, "userId": user_id, "properties": properties or {}, "clientTimestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), }] }, headers={"X-API-Key": API_KEY}) return resp.json()
track("user_paid", "vid_abc123", "uid_456", {"amount": 9900, "currency": "CNY"})func TrackEvent(apiKey, eventName, visitorID, userID string, props map[string]any) error { body, _ := json.Marshal(map[string]any{ "events": []map[string]any{{ "eventName": eventName, "visitorId": visitorID, "userId": userID, "properties": props, "clientTimestamp": time.Now().UTC().Format(time.RFC3339), }}, }) req, _ := http.NewRequest("POST", "https://api.retidal.com/api/v1/t", bytes.NewReader(body)) req.Header.Set("X-API-Key", apiKey) req.Header.Set("Content-Type", "application/json") _, err := http.DefaultClient.Do(req) return err}curl -X POST https://api.retidal.com/api/v1/t \ -H "Content-Type: application/json" \ -H "X-API-Key: your-api-key" \ -d '{ "eventName": "user_paid", "visitorId": "vid_abc123", "userId": "uid_456", "properties": { "amount": 9900, "currency": "CNY" }, "clickIds": { "bd_vid": "xxx-click-id" } }'Engineering notes
Section titled “Engineering notes”- Buffer high-frequency events client-side and flush in batches of up to 100 rather than firing one request per event.
- Retry failed requests with backoff; for
navigator.sendBeacon()(no retry, no headers) use the?_ak=query fallback. - Keep your API key in an environment variable, never hardcoded.
What this unlocks
Section titled “What this unlocks”Once events are flowing, Identifying users links them to a
known userId, and the event catalog shows their
volume and downstream consumers — neither works until POST /api/v1/t is sending real
traffic.
Verify it worked
Section titled “Verify it worked”Send a synchronous test event and confirm the body proves persistence, not just acceptance:
curl -s -X POST "https://api.retidal.com/api/v1/t?sync=1" \ -H "X-API-Key: $RETIDAL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"eventName":"healthcheck","visitorId":"vid_test"}'# expect: 200 { "accepted": 1, "failed": 0, "queued": 0, "processed": true }# processed: true is the only proof the event reached storage — a 202 from the# default async path only proves it was accepted for background processingYou can also see the event land in Debug view
(Console → Projects → Debug) with debug mode enabled.
If it doesn’t work
Section titled “If it doesn’t work”Work through Events not arriving — it covers
the POST /api/v1/t 401/422/503 responses and how to distinguish an accepted
response from a persisted one.
Next steps
Section titled “Next steps”Link an anonymous visitor to a known user, and unlock event-triggered email.
Attach ad-platform Click IDs to your events for conversion callback.