# Track events

> Send product events with POST /v1/events/batch or POST /v1/events/track, with the event fields, validation rules and responses.

Send product events to Whisperr with `POST /v1/events/batch` or `POST /v1/events/track`. This page lists every field of an event, how Whisperr validates it, and what the response means.

| Endpoint                | Body                                            |
| ----------------------- | ----------------------------------------------- |
| `POST /v1/events/batch` | `{ "events": [ <event>, … ] }`, 1 to 500 events |
| `POST /v1/events/track` | One `<event>`                                   |

Both endpoints accept the publishable key (`wpk_…`) and the server key (`wrk_…`). See [Which API key do I use?](/api/overview/#which-api-key-do-i-use)

## The event object

```json
{
  "external_user_id": "user_8842",
  "event_type": "payment_failed",
  "occurred_at": "2026-06-14T12:00:00.000Z",
  "properties": { "amount_cents": 4900 },
  "context": { "$message_id": "f7a1c2e0-4b3d-4f6a-9c1e-8d2b5a7e3f10" }
}
```

### `external_user_id` string

Your own stable user id: the same id you use in your database and send to [identify](/api/identify/). Events from your app and your backend with the same id land on one timeline.

### `anonymous_id` string

A handle for a visitor you cannot identify yet, 1 to 128 printable characters. The browser SDK generates a UUID per browser profile. Send it in place of `external_user_id`. Each event needs `external_user_id`, `anonymous_id`, or both. When both are present, the event belongs to `external_user_id`.

A later [identify](/api/identify/) that carries the same `anonymous_id` with an `external_user_id` moves the anonymous events to that user. Whisperr never messages an anonymous user.

### `event_type` string, required

Lowercase `snake_case` that matches `^[a-z0-9]+(?:_[a-z0-9]+)*$`. Whisperr trims the value and changes it to lower case, then checks it. Any other value is rejected. Validate names before you queue events, so one bad name does not cost you an event. See [Event design](/concepts/events/) for naming guidance.

You cannot send an event name that Whisperr computes itself. Such an event is accepted and then rejected. See [Rejections after acceptance](/api/delivery/#rejections-after-acceptance).

### `occurred_at` string

When the event happened, as an RFC 3339 timestamp. The SDKs send UTC with milliseconds and a `Z` suffix: `2026-06-14T12:00:00.000Z`. It must be no more than 5 minutes in the future and no more than 30 days in the past. Without it, Whisperr uses the time it accepted the request.

Capture it when the event happens, not when you send it, so buffered and offline events keep their real time.

### `properties` object

Free-form event attributes, up to 50 KB as JSON. An empty value is `{}`, never `[]`.

Do not send personal data in properties. Whisperr always drops a property whose key names personal data or a secret, such as an email, phone, address, name or password key. It does this in `context` too. It keeps keys that the event’s registration does not declare, and flags them for 30 days. The **Data** page of the dashboard lists dropped keys under **Not used → Personal data removed**. The Whisperr MCP tool `get_received_events` lists dropped and undeclared keys. Send contact addresses with [identify](/api/identify/), not on events.

### `context` object

Free-form metadata about the event. Put the idempotency key in `context.$message_id`: a unique string per event, up to 200 bytes. A UUID works. Keep it the same on every retry of the event. Whisperr stores an event once per `$message_id`, so retries never create duplicates. Whisperr accepts an event without `$message_id`, but cannot deduplicate it. See [Idempotency](/api/delivery/#idempotency-message_id).

With the publishable key, Whisperr adds `context.country`: the country of the request’s IP address, found at the edge. It keeps a `country` you set yourself. Server-key events never get it.

### `user` object

Optional contact data for the event’s user, with the same rules as [identify](/api/identify/):

- `preferred_channel`: `email` or `push`.
- `channels`: up to 10 channel objects, as in [`channels`](/api/identify/#channels-array).

The SDKs do not send `user`. Use identify unless you must send contact data with the event.

## Send a batch

`POST /v1/events/batch` takes 1 to 500 events. Split a larger backlog into several requests, each with its own retry state.

```bash
curl https://api.whisperr.net/v1/events/batch \
  -H "X-API-Key: $WHISPERR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "events": [
    {
      "external_user_id": "user_8842",
      "event_type": "payment_failed",
      "occurred_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
      "properties": { "amount_cents": 4900 },
      "context": { "\$message_id": "$(uuidgen)" }
    },
    {
      "external_user_id": "user_9917",
      "event_type": "trial_expired",
      "occurred_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
      "properties": {},
      "context": { "\$message_id": "$(uuidgen)" }
    }
  ]
}
EOF
```

The shell fills in the time and a new `$message_id` for each event. In your own code, generate `$message_id` once when the event is created.

## Send one event

`POST /v1/events/track` takes one event as the whole body.

```bash
curl https://api.whisperr.net/v1/events/track \
  -H "X-API-Key: $WHISPERR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "external_user_id": "user_8842",
  "event_type": "plan_downgraded",
  "properties": { "from_plan": "pro", "to_plan": "basic" },
  "context": { "\$message_id": "$(uuidgen)" }
}
EOF
```

## What do the event endpoints return?

Both endpoints answer `202 Accepted` once the request is stored. Whisperr processes the events right after, normally within seconds.

`POST /v1/events/batch`:

```json
{ "accepted": 2, "rejected": 0 }
```

`rejected` counts events that failed validation, such as an unknown field, a bad `event_type` or an `occurred_at` outside the window. Whisperr skips them and accepts the rest. A problem with the whole request fails it with `400`: invalid JSON, an unknown top-level field, or an empty or oversized `events` array.

`POST /v1/events/track`:

```json
{ "event": { "mapping_status": "pending" }, "processing": "queued" }
```

On `track`, an event that fails validation is a `400`.

Decide what to do from the status code, not from the body. See [Response handling](/api/delivery/#response-handling) for every status, and [Rejections after acceptance](/api/delivery/#rejections-after-acceptance) for the problems that show up only after the `202`.

## Common questions

### Is occurred_at required on a Whisperr event?

No. Without occurred_at, Whisperr uses the time it accepted the request. Send it anyway when the event happened earlier, for example from an offline queue, so the event keeps its real time.

### Does Whisperr accept uppercase event names?

Whisperr trims the event_type and changes it to lower case before it checks it, so Payment_Failed is stored as payment_failed. Send lowercase snake_case names: the SDKs reject anything else before they send it.

### What happens if I send the same event twice to Whisperr?

Whisperr stores an event once per $message_id. A second event with the same context.$message_id is accepted and ignored. An event without $message_id cannot be deduplicated.

### Can I send events before I know who the user is?

Yes. Send anonymous_id instead of external_user_id. When a later identify call carries both the anonymous_id and the external_user_id, Whisperr moves the anonymous events to the identified user. Whisperr never messages an anonymous user.

Source: https://docs.whisperr.net/api/events/
