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).
{
"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, defaulttrue.falsemarks marketplace volume processed for others, reported by thevolumemetric, never inrevenue.idrequired;sk_live_keys only. A public key attempting a payment gets a per-item rejection (whole-batch money viapk→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 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:
{
"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.