POST /v1/events

Last updated Aug 12, 2026View as MarkdownAgent setup

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" }
}
FieldRequiredNotes
name1–128 chars. The only required field.
userOpaque string, ≤256 chars. Never format-validated.
idIdempotency key. Required if you retry. Required for payment.
atWhen it happened (your clock). RFC 3339 or epoch ms.
sent_atWhen you flushed the batch (your clock). Lets us cancel clock skew.
propsFlat 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 pk403).

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:

SDKOn a 402 (or any non-2xx)
@datagauge/clientKeeps 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/nodeKeeps 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/edgeThe 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/convexThe 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.