---
title: Sending events
description: 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

- A Retidal project and API key — see [Authentication](/docs/authentication).

## Endpoint

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

<Warning title="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.
</Warning>

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

<CodeGroup>
```json title="Single event"
{
  "eventName": "user_paid",
  "visitorId": "vid_abc123",
  "userId": "uid_456",
  "sessionId": "sess_789",
  "properties": { "amount": 9900, "currency": "CNY" },
  "clickIds": { "bd_vid": "abc", "gclid": "xyz" }
}
```
```json title="Batch (array)"
[
  { "eventName": "page_view", "visitorId": "vid_abc123" },
  { "eventName": "user_paid", "visitorId": "vid_abc123", "properties": { "amount": 4900 } }
]
```
```json title="Wrapped batch"
{
  "events": [
    { "eventName": "page_view", "visitorId": "vid_abc123" },
    { "eventName": "user_paid", "visitorId": "vid_abc123", "properties": { "amount": 4900 } }
  ]
}
```
</CodeGroup>

### Fields

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

<ParamField name="eventName" in="body" type="string" required>
  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.
</ParamField>
<ParamField name="visitorId" in="body" type="string">
  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.
</ParamField>
<ParamField name="userId" in="body" type="string">
  Logged-in user ID. Alias `user_id`.
</ParamField>
<ParamField name="sessionId" in="body" type="string">
  Session-scoped ID. Alias `session_id`. This is your Tier-1 attribution key — see
  [Cross-device attribution](/docs/attribution/cross-device).
</ParamField>
<ParamField name="properties" in="body" type="object">
  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 units** — `9900` means ¥99.00. `currency` defaults to `CNY`.
</ParamField>
<ParamField name="eventId" in="body" type="string">
  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](#backfill).
</ParamField>
<ParamField name="clientTimestamp" in="body" type="string">
  ISO 8601 event time. Aliases `client_timestamp`, `timestamp`. Unparseable values
  are a warn-level error.
</ParamField>
<ParamField name="shortLinkId" in="body" type="string">
  Short-link record ID, read from the `_tl` query parameter after a short-link
  redirect. Alias `short_link_id`. See [Short links](/docs/attribution/short-links).
</ParamField>
<ParamField name="shortLinkCode" in="body" type="string">
  Short code for direct-landing attribution (no redirect involved). Aliases `_tk`,
  `short_link_code`.
</ParamField>
<ParamField name="clickIds" in="body" type="object">
  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](/docs/sending-data/click-ids) for the full field list and the
  legacy top-level alternative.
</ParamField>

## 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`)

```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.
- `processed` — **always `false`** on this path.

<Danger title="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.
</Danger>

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`

<CodeGroup>
```bash title="cURL"
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"}'
```
```json title="Response"
{ "accepted": 1, "failed": 0, "queued": 0, "processed": true }
```
</CodeGroup>

`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

`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`:

```json title="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" }
      ]
    }
  ]
}
```

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

`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`:

```json title="400 — missing eventId on a metering event"
{
  "error": "MISSING_EVENT_ID",
  "affectedIndices": [0, 3],
  "message": "计量级事件 backfill 必须提供 eventId"
}
```

## Sending code samples

<CodeGroup>
```javascript title="JavaScript"
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 title="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 title="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
}
```
```bash title="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" }
  }'
```
</CodeGroup>

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

Once events are flowing, [Identifying users](/docs/sending-data/identify) links them to a
known `userId`, and the [event catalog](/docs/sending-data/event-catalog) shows their
volume and downstream consumers — neither works until `POST /api/v1/t` is sending real
traffic.

## Verify it worked

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](/docs/sending-data/debug-view)
(`Console → Projects → Debug`) with debug mode enabled.

## If it doesn't work

Work through [Events not arriving](/docs/troubleshooting/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

<CardGroup cols={2}>
  <Card title="Identify users" href="/docs/sending-data/identify">
    Link an anonymous visitor to a known user, and unlock event-triggered email.
  </Card>
  <Card title="Click IDs" href="/docs/sending-data/click-ids">
    Attach ad-platform Click IDs to your events for conversion callback.
  </Card>
</CardGroup>
