# POST /v1/events

Append-only events. Body: **one JSON object or an array** of them (offline
replay), max 500 items, and 1 MB of body measured in **UTF-8 bytes** (so
multibyte text counts as the bytes it is, not the characters it has).

```json
{
  "name": "user_signup",
  "user": "usr_8813",
  "id": "evt_01J9XW...",
  "at": "2026-07-18T09:14:02.123Z",
  "sent_at": "2026-07-18T09:14:07.001Z",
  "props": { "plan": "pro" }
}
```

| Field | Required | Notes |
|---|---|---|
| `name` | ✓ | 1–128 chars. The only required field. |
| `user` | | Opaque string, ≤256 chars. Never format-validated. |
| `id` | | Idempotency key. **Required if you retry.** Required for `payment`. |
| `at` | | When it happened (your clock). RFC 3339 or epoch ms. |
| `sent_at` | | When you flushed the batch (your clock). Lets us cancel clock skew. |
| `props` | | Flat object of scalars: one level deep, ≤100 keys. |

Unknown top-level fields are ignored, never rejected.

**Reserved names.** Two `name` values mean something specific: `user_signup` is
what the **signups** metric counts (distinct users who signed up), and `payment`
routes to the exact-money store (below). Every other name is a behavioral event,
counted toward MAU and DAU. Send `user_signup` on registration, not a custom
name, or your signups stay at zero.

## Timestamps

Send `at` and `sent_at` from an offline queue and the server corrects your
clock skew (`occurred = at + (received − sent_at)`). Send only `at` and
it's trusted. Send neither and arrival time is used. Everything is clamped
to `[now − 7 days, now + 5 minutes]`; the raw claim is preserved for audit.

## Payments

`name: "payment"` routes to the exact-money store:

- `props.amount`: **integer minor units** (cents). Floats, strings, and
  negatives are rejected, never coerced.
- `props.currency`: ISO-4217 (`"usd"`).
- `props.earned`: optional boolean, default `true`. `false` marks
  marketplace volume processed for others, reported by the `volume`
  metric, never in `revenue`.
- `id` required; `sk_live_` keys only. A public key attempting a payment
  gets a per-item rejection (whole-batch money via `pk` → `403`).

## Responses

`202 {"ok":true,"accepted":N,"rejected":M}` means the events are in a
durable queue, not "we'll try." Mixed batches are partially accepted with
an `errors: [{index, reason}]` array. `4xx` only for bad auth, malformed
JSON, size limits, or scope violations. Ingest never returns `5xx` for its
own backpressure: your signup flow must not know we exist.

There is exactly one exception, and it is deliberate: `503 {"ok":false}` if we
could not put your events somewhere durable: either the queue refused the
write (`"could not queue batch, retry"`) or something on our side failed
before we got that far (`"internal, retry"`). Nothing was stored and nothing
was billed, so keep the batch and retry it. We would rather tell you than answer
`202` for events that are not actually anywhere. `@datagauge/client` and
`@datagauge/node` keep the batch for you on any non-2xx, so this needs no code
from you there; see the table below for the two that do not.

One `4xx` is about your account rather than your request:
`402 {"ok":false,"code":"ingest_suspended"}` if there is no live subscription
behind it, either because an invoice went unpaid long enough for its grace
period to expire or because the subscription itself ended. Nothing you have
already sent is deleted and the read APIs keep working; paying the invoice, or
subscribing again, restores ingest within seconds, with nothing to ask us for.
If you have paid and are still being refused several minutes later, that is a
bug on our side and not a policy; email support and we will clear it.

**What a refusal costs you depends on which SDK you use**, and two of the four
cannot hold the batch for you. All four say what happened as of 0.2.0; only two
can do anything about it. Worth knowing before a suspension rather than after:

| SDK | On a `402` (or any non-2xx) |
| --- | --- |
| `@datagauge/client` | Keeps the queue and replays it. Bounded by `maxQueue` (1000 by default; the oldest are dropped past that) and durable across restarts if you pass a `storage`. A short suspension costs you only the overflow. From 0.2.0 it drops a batch only when the refusal is one repeating cannot fix (a `400`, `415` or `422`), and calls `onError` when it does, so a drop is never silent. |
| `@datagauge/node` | Keeps the buffer and reports `{ ok: false, suspended: true }` from `flush()` (`capped` for a hard cap). From 0.2.0 every failure also carries `reason`, a sentence naming the cause. In memory only: this SDK is for CLIs and cron jobs, so if the process exits before a later flush succeeds, the batch goes with it. |
| `@datagauge/edge` | **The events are lost, but not quietly** (0.2.0 and later). The wrapper reads the response and logs the refusal with its cause named, or hands it to your `onError`. It cannot do more: these runtimes can freeze the moment the response returns, so there is nowhere to hold a batch for later. On `0.1.0` the response was discarded and the loss was silent. |
| `@datagauge/convex` | **The events are lost, but not quietly** (0.2.0 and later). The handler reads the response and logs the `402` with its cause named, or hands it to your `onError`. It cannot do more: a `402` is not retriable, and Convex actions do not re-run on their own. On `0.1.0` the response was discarded and the loss was silent. |

If you send from `@datagauge/edge` and cannot afford to lose events during a
billing lapse, its `onError` will tell you it happened, but only something that
outlives the response can act on it: hold the batch yourself, or route those
sends through a server using `@datagauge/node`. `@datagauge/convex` will tell
you it happened too, and for a `429` or a `5xx` it throws, so
[`@convex-dev/action-retrier`](https://www.convex.dev/components/retrier) can
recover the batch. A `402` is the case no retry fixes: if those events matter
more than the alert does, hold them in a table of your own until the invoice is
paid. Ingest
degrades open in every ambiguous case, so a `402` is only ever a positive
statement that the subscription behind the account is gone, never an
infrastructure hiccup.

### If you set a hard cap

If your organization has set a hard cap on events per billing period, requests
past it are refused with a `403` that carries a machine-readable code:

```json
{
  "ok": false,
  "error": "event cap reached: this organization set a hard cap of 1000000 events for the current billing period",
  "code": "event_cap_reached",
  "cap_events": 1000000,
  "resets_at": 1798761600000
}
```

Match on `code`, never on `error`: the prose can change, the code cannot. It is
a `4xx` because nothing is broken: you asked for this, so an SDK can tell it
apart from an outage and stop retrying instead of hammering a ceiling. The
official SDKs already do; `resets_at` is epoch milliseconds, and the cap lifts
by itself at your billing period boundary.

Events refused this way are **not stored and not billed**. Events accepted just
before the cap engaged are stored as normal; the cap is enforced from a
mirrored record that can lag your counter by a few tens of seconds, and that
overshoot is on us, never on your invoice. Only `/v1/events` is capped;
`/v1/objects` is not metered and is never refused for this reason.

A single item that serializes to more than 120 KB is rejected per-item (it
cannot fit one queue message). The per-field limits (128-character `name`,
256-character `user` and `id`, 100 `props` keys of ≤ 1024 characters) count
characters, and an item of plain ASCII at all of them at once is about 117 KB.
You can still exceed 120 KB inside those limits with multibyte text or with
`props` values full of quotes or backslashes, which are escaped twice on the way
to storage; the rejection tells you the byte count.

Retrying the same `id` with the **same** payload → deduped, still `202`. For
behavioral events the collapse happens when the number is read, so a retry
cannot inflate a count no matter how many times it lands.

For `payment` events, the same `id` with a **different** payload →
first-write-wins; the conflicting write is rejected and counted (that's a bug
in your integration we surface rather than hide). Behavioral events carry no
such conflict check: reuse an `id` for two different behavioral payloads and
both are stored, then counted once.
