Skip to documentation
Coal Developer docs
User metricsDirect API

Direct API

Build your own integration in any language that can send HTTPS and JSON. The public ingestion endpoint is POST https://coal-events.fly.dev/v1/events. It uses the same game key as the SDK. You own consent, persisted identities, session timing, progression state, offline storage and retries.

Before implementing, read Events and metrics: it explains which events produce activity, retention, session duration and progression metrics, and how to choose meaningful stages.

Get a game key

Select your game in Coal and open User metrics → Setup. An organization administrator with access to the game can create and copy its public ingestion key. Creating the first key activates collection. The game's owning organization must retain eligible Coal access.

Send the key in X-Coal-Ingestion-Key and JSON in Content-Type: application/json. All games share the same collector address. Browser, itch, packaged and server clients use the same protocol. Set browser fetch requests to credentials: 'omit'. The collector handles browser preflight with wildcard CORS.

The public key identifies the game. Use the event envelope below, place custom fields in properties, and send uncompressed JSON to the endpoint.

Send your first batch

Send a JSON batch with your game's key. For gameplay, generate the event and session UUIDs, reuse the saved installation UUID, and record the UTC occurrence time. For local testing, use your local collector URL and key.

curl --fail-with-body 'https://coal-events.fly.dev/v1/events' \
  -H 'Content-Type: application/json' \
  -H 'X-Coal-Ingestion-Key: yourkey' \
  --data-binary @- <<'JSON'
{
  "schema_version": 1,
  "events": [{
    "event_id": "00000000-0000-4000-8000-000000000021",
    "installation_id": "00000000-0000-4000-8000-000000000022",
    "session_id": "00000000-0000-4000-8000-000000000023",
    "name": "session_started",
    "occurred_at": "2026-10-10T12:00:00.000Z",
    "properties": { "active_duration_ms": 0 },
    "platform": "web",
    "app_version": "1.0.0",
    "schema_version": 1
  }]
}
JSON

Response:

{"acknowledged":1,"inserted":1,"duplicates":0}

Retry the same batch unchanged. An identical retry returns {"acknowledged":1,"inserted":0,"duplicates":1}. Persist event snapshots in your app’s delivery queue until acknowledged.

Event envelope and limits

Field Required Contract
Batch schema_version Yes 1.
Batch events Yes 1–100 event objects; entire request up to 256 KiB.
event_id Yes Nonzero UUID in 36-character hyphenated form, unique per new event within the game.
installation_id Yes Nonzero UUID in the same form, persisted by the client.
session_id Yes Nonzero UUID in the same form for this session.
name Yes 1–100 ASCII characters; starts with a letter, followed by letters, digits, _, ., :, or -.
occurred_at Yes UTC RFC3339 timestamp, optionally with fractional seconds.
properties No for custom events JSON object, up to 8 KiB; numbers in the JavaScript range.
platform Yes Nonempty string, up to 64 UTF-8 bytes.
app_version Recommended String, up to 100 UTF-8 bytes; the SDK always supplies it.
Event schema_version Yes 1.

Sizes are UTF-8 encoded bytes: 256 KiB per request, 16 KiB per event and 8 KiB per properties object. Custom events may omit properties; when supplied, use a JSON object with finite numbers and valid Unicode text compatible with PostgreSQL JSONB. Place custom fields inside properties. Keep the original occurrence time for offline delivery.

Installation and session identity

Generate a random installation UUID once after your app's consent policy permits collection, then persist it in app storage scoped to this game. Reuse it across launches and key changes for the same game. Clearing storage or explicitly resetting identity creates a new installation.

Generate a random session UUID when a session starts. All events in that session share it and the installation UUID, while every new event gets its own event UUID.

To match the SDK's session behavior, resume the same persisted session and cumulative duration after a reload or return within 30 minutes away, without counting the downtime. Start a new session after at least 30 minutes away. Keep separate sessions for independent concurrent app windows. Persist session state and away timestamps if your runtime can be restarted during a session.

Sessions and active duration

These reserved event names feed Coal's session duration metrics:

Name When to emit Required properties
session_started Once when a new session begins. {"active_duration_ms":0}
session_heartbeat About every 30 seconds while the app is in the foreground. {"active_duration_ms":30000} for 30 seconds accumulated so far.
session_ended Best effort when a session finishes. The final cumulative active_duration_ms.

active_duration_ms is a nonnegative JSON number no greater than 31,536,000,000. Use integer milliseconds for predictable aggregates. It is cumulative for the whole session. For example, heartbeats at 30 and 60 active seconds carry 30000 and 60000; the observed duration is 60 seconds. Send duration on these reserved session events.

Measure elapsed time with a monotonic clock, accumulate time while foregrounded and cap samples during timer stalls. The JS SDK samples each second and caps a sample at two seconds. Preserve the accumulated value when resuming a session. Use wall-clock UTC for occurred_at.

Persist regular heartbeats throughout the session. Sessions and their maximum observed duration belong to their first observed UTC day, even when they cross midnight.

Progression attempts

Use progression_started, then one matching progression_completed or progression_failed event. All three require properties.attempt_id (a nonzero UUID) and properties.progression_id (a nonempty string up to 100 UTF-8 bytes). Use an optional integer properties.progression_order to order your levels or stages, consistently across events.

An attempt is one try at a stage. Generate its UUID once at the actual start and save it with your game checkpoint. Reuse it for the outcome, even after restart or a new session; keep the same installation and progression ID. A new try gets a new attempt UUID. The start and outcome are distinct events and have distinct event UUIDs. Send the start once, retrying its original event if delivery fails. Emit one terminal outcome per attempt.

Here is a complete two-event batch illustrating the fields. Replace all example UUIDs and timestamps for actual new gameplay events.

{
  "schema_version": 1,
  "events": [
    {
      "event_id": "00000000-0000-4000-8000-000000000031",
      "installation_id": "00000000-0000-4000-8000-000000000032",
      "session_id": "00000000-0000-4000-8000-000000000033",
      "name": "progression_started",
      "occurred_at": "2026-10-10T12:00:00.000Z",
      "properties": {
        "attempt_id": "00000000-0000-4000-8000-000000000034",
        "progression_id": "level_1",
        "progression_order": 1,
        "difficulty": "normal"
      },
      "platform": "ios",
      "app_version": "1.0.0",
      "schema_version": 1
    },
    {
      "event_id": "00000000-0000-4000-8000-000000000035",
      "installation_id": "00000000-0000-4000-8000-000000000032",
      "session_id": "00000000-0000-4000-8000-000000000033",
      "name": "progression_completed",
      "occurred_at": "2026-10-10T12:01:00.000Z",
      "properties": {
        "attempt_id": "00000000-0000-4000-8000-000000000034",
        "progression_id": "level_1",
        "progression_order": 1,
        "score": 120
      },
      "platform": "ios",
      "app_version": "1.0.0",
      "schema_version": 1
    }
  ]
}

For failure, use progression_failed and your own small properties such as reason. Outstanding attempts appear as unresolved. Daily progression metrics belong to the attempt start day and use the earliest matching outcome.

Durable queues and acknowledgments

  1. At the gameplay action, create the event UUID and snapshot its occurrence time and full payload. Persist it before attempting delivery.
  2. Send a bounded batch of 1–100 queued events within the byte limits. Flush regularly, for example every five seconds.
  3. Remove that batch only after HTTP 200 and a parsed integer acknowledged equal to the number you sent. Both inserted events and duplicates count as acknowledged.
  4. On a timeout, lost connection or retryable response, retain and retry the same event IDs and payloads. A timeout may happen after commit; deduplication makes the retry safe.

The whole validated batch commits atomically. Equivalent JSON retries deduplicate by game and event ID, including when delivered with another active key for that game. Preserve the original event snapshot for every retry.

Bound the durable queue and expose failures to developers without affecting gameplay. The SDK's defaults are a useful starting point: 1,000 events, 2 MiB and seven days, evicting oldest events on overflow and expiring aged entries. These are client queue policies. Record dropped/expired counts. Coordinate simultaneous queue writers and flushers so acknowledgments remove only their submitted events.

Errors and retry policy

Error responses are JSON, for example:

{"error":"event_conflict","message":"event_id already has a different payload; entire batch rejected"}
Status / condition Meaning Client action
200 with matching acknowledged Whole batch committed or was already stored. Remove the submitted events from the queue. inserted and duplicates explain the result.
Network error, timeout, 408, 429, or 5xx Delivery failed or is uncertain; rate/concurrency limits may apply. Keep events and retry with exponential backoff and jitter. Respect Retry-After when accessible.
400 / invalid_batch Invalid JSON, field, value or schema version. Pause automatic retries, inspect the message and fix the producer. Quarantine invalid events.
401 / key Missing or malformed key header. Fix the key/header before resuming.
403 / access_denied Unknown/revoked key or unavailable game access. Check User metrics → Setup and account eligibility; retain pending events subject to queue bounds.
409 / event_conflict An event UUID already has a different payload. Inspect the conflicting event and restore its original saved snapshot.
413 / size Body too large or unreadable. Reduce batch size while keeping individual events unchanged; quarantine an oversized event.
415 / content_type Wrong content type, query string or compression. Send uncompressed JSON to https://coal-events.fly.dev/v1/events.
405 / method Wrong HTTP method. Use POST.
2xx without a matching acknowledgment Delivery is uncertain or the response is unexpected. Retain the batch; investigate and retry the saved event snapshots.

A practical retry schedule starts around one second, doubles with jitter, and caps the delay at 60 seconds unless Retry-After requires longer. Use this backoff policy for cross-origin browser requests. Keep permanent errors observable and resume only after fixing their cause. Apply a finite request timeout, such as ten seconds, and retry the same saved events on timeout.

Your app must decide when collection is permitted, independently of transport. On reporting-off or consent withdrawal, stop creating events and stop flushing; clear pending events if they should no longer be sent. Committed events remain in the daily aggregates. On an explicit identity reset, clear pending events and saved session/attempt state before creating a new installation UUID.

Use a local collector to verify delivery, unchanged retries, offline persistence, foreground timing, progression recovery and reporting-off. Send small structured gameplay properties.

An HTTP acknowledgment confirms durable ingestion immediately. Coal displays daily UTC aggregates after the next dbt run, about every 15 minutes. Overview, Retention and Progression use installation/session/attempt identities as described above. Custom actions also contribute to activity and session counts.

For an AI implementing a custom client, provide the Markdown guide and ask it to follow this Collection API section, including persisted identities, reserved event properties, cumulative foreground duration, original retry snapshots, exact acknowledgments, consent and local test coverage.