Gamearly API v1
Dashboard

In-game events / Sending events

Sending events#

POST /v1/events/track#

Batch ingest. Up to 200 events per request.

{
  "default": {
    "environment": "production",
    "build_version": "1.4.2",
    "platform": "windows",
    "partner_user_id": "A1B2C3"
  },
  "events": [
    {
      "id": "9a0e5f1c-6b2d-4c8e-9a11-7d3f2b4c6e88",
      "name": "killed_boss",
      "occurred_at": "2026-08-19T10:00:00Z",
      "count": 1,
      "value": 1234.5,
      "session_id": "s-123",
      "variant": "B",
      "properties": { "level": 3, "weapon": "axe", "difficulty": "hard" }
    }
  ]
}

default is merged into every event that does not set the field itself — worth using, since it removes the repeated build, platform and user from all 200 items.

The fields on an event#

Field Required What it does
id yes Your unique id for this event. Reuse it when you retry — see below.
name yes The event name. Created in your catalog on first sight.
occurred_at no When it happened, ISO 8601. Defaults to arrival.
count no Report the same event n times in one item. Defaults to 1.
value no A number to sum and average — score, damage, currency. Unlocks the value_sum and value_avg metrics.
session_id no Your own id for the play session. See below.
properties no Up to 50 keys of your own. This is what breakdowns are built from.
partner_user_id / device_id one of Who it happened to.
environment, build_version, platform, variant no Context. Each becomes a filter and a breakdown on your dashboard.

All of these except id, name and properties may also be set once in default.

session_id unlocks three views, and you do not have to send it

If you send one, your dashboard gets a per-session distribution ("how many deaths in a typical session"), journeys grouped by session, and the sessions and per_session metrics — all measured against your definition of a session, which is the one that matches your game.

If you do not, we derive sessions by splitting each player's events on a 30-minute gap of inactivity. Those three views work either way; they are just less precise. Sending an id is worth it if your game knows when a session really starts and ends — a player who idles in a menu for 40 minutes is one session to you and two to us.

Any stable string works. A UUID minted at launch is the usual shape.

id is required on every event

It is your own unique id for that event, and you must reuse the same id when you retry. It is what makes the acknowledgement meaningful and what lets us discard replays instead of double-counting them. A UUIDv4 generated at emission and stored with the queued event is the right shape.

The response partitions your batch exactly#

{
  "success": true,
  "accepted":   ["9a0e5f1c-…", "b1c2d3e4-…"],
  "duplicated": ["7f8e9d0c-…"],
  "throttled":  ["3a4b5c6d-…"],
  "rejected":   [
    { "id": "1122aabb-…", "reason": "name_not_allowed",
      "tr_key": "error.ingame_event.name_not_allowed" }
  ],
  "catalog_created": ["killed_boss"],
  "server_time": "2026-08-19T10:00:03Z"
}

Every id you sent comes back in exactly one list.

List Meaning Action
accepted Durably queued. It will become a row. Drop it from your outbox.
duplicated We already have this id. Drop it from your outbox.
throttled Refused for now — rate limit, budget, or our buffer is deep. Resend later, in a normal-sized batch. Honour retry_after if present.
rejected Permanently refused, with a reason. Do not resend. Fix the integration.
absent from all four Should not happen; treat as unacknowledged. Resend later.

rejected reasons include name_invalid, name_not_allowed, name_limit_reached, actor_required, count_invalid, count_too_large, value_out_of_range, properties_too_large, occurred_at_invalid, occurred_at_too_old, client_event_id_required.

Retry with more batches, not bigger ones

After an outage, resend your backlog as a series of ≤200-event requests. A single huge catch-up batch arrives exactly when duplicates are worst and is refused.

accepted means durably queued, not immediately queryable

Events are buffered and written by a background worker a moment later, so an event you just sent may not appear in /events/trends or /events/user for a short period. This is deliberate: it is what keeps ingest fast and independent of query load. Do not poll for your own event immediately after sending it.

Deduplication#

We discard a repeat of the same id for 48 hours, which comfortably covers any sane retry schedule including an overnight outage. Past that window a replay may be recorded again, so do not use /events/track to re-send history deliberately — use /events/import.

Timed events#

POST /v1/events/time/start opens a stopwatch and records nothing. Nothing has happened yet, and emitting an event here would double-count every timed action.

{ "name": "boss_fight", "partner_user_id": "A1B2C3", "timer_key": "boss_fight" }

POST /v1/events/time/stop closes it and records the real event carrying duration_ms. From then on it is an ordinary event: it appears in the same trends, funnels and quest counts as any other.

One timer may be open per player and timer_key. Use timer_key to run several at once. A timer left open for 24 hours is expired automatically — a player who quit mid-fight does not hold the slot forever. Sending your own duration_ms on stop overrides our arithmetic, which is what you want if you pause the clock during loading screens.

POST /v1/events/identify#

Tell us that an anonymous device belongs to one of your users.

{ "device_id": "dev-9f3a21", "partner_user_id": "A1B2C3" }

Everything that device already reported is re-attributed in the background. Call it the moment a player signs in on a device that was previously anonymous.

Note

A shared machine is handled: one device_id may map to different users over time, and an event already credited to one player is never re-credited to the next.

POST /v1/events/import#

Bulk historical backfill, up to 1000 events. Requires events:import, which only your private key holds — arbitrary timestamps are not something a game build gets to assert.

Same request body as /events/trackevents (required), plus the optional default block and test flag. The only differences are the higher batch cap and that occurred_at may be arbitrarily old.

Imported events count in every chart but never trigger real-time quest progress — a bulk backdating endpoint reachable from a game client would be a cheat vector, which is why the scope is not granted implicitly.

Ordinary /events/track already accepts backdating up to 30 days, which covers an offline client catching up. Older than 90 days is rejected outright.