Skip to content

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.

POST https://api.retidal.com/api/v1/t

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

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" }
}
Batch (array)
[
{ "eventName": "page_view", "visitorId": "vid_abc123" },
{ "eventName": "user_paid", "visitorId": "vid_abc123", "properties": { "amount": 4900 } }
]
Wrapped batch
{
"events": [
{ "eventName": "page_view", "visitorId": "vid_abc123" },
{ "eventName": "user_paid", "visitorId": "vid_abc123", "properties": { "amount": 4900 } }
]
}

Every field below accepts both camelCase and snake_case (for example visitorId / visitor_id).

eventNamebodystringrequired

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

visitorIdbodystring

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

userIdbodystring

Logged-in user ID. Alias user_id.

sessionIdbodystring

Session-scoped ID. Alias session_id. This is your Tier-1 attribution key — see Cross-device attribution.

propertiesbodyobject

Free-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 units9900 means ¥99.00. currency defaults to CNY.

eventIdbodystring

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

clientTimestampbodystring

ISO 8601 event time. Aliases client_timestamp, timestamp. Unparseable values are a warn-level error.

shortLinkIdbodystring

Short-link record ID, read from the _tl query parameter after a short-link redirect. Alias short_link_id. See Short links.

shortLinkCodebodystring

Short code for direct-landing attribution (no redirect involved). Aliases _tk, short_link_code.

clickIdsbodyobject

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

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)”
json
{ "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 (missing eventName, unparseable JSON for that item, etc).
  • queued — metering events enqueued to the metering ingest queue.
  • processedalways false on 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"}'
Response
{ "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.

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:

422 — all events enforce-rejected
{
"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" }
]
}
]
}
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.

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:

400 — missing eventId on a metering event
{
"error": "MISSING_EVENT_ID",
"affectedIndices": [0, 3],
"message": "计量级事件 backfill 必须提供 eventId"
}
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" });
Python
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"})
Go
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
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" }
}'
  • 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.

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.

Send a synchronous test event and confirm the body proves persistence, not just acceptance:

bash
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 processing

You can also see the event land in Debug view (Console → Projects → Debug) with debug mode enabled.

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.