<!-- Kind: Integration guide. Status: Preview. Maintained with: sdk/event-collection and internal/eventcollection. Served at /user-metrics/api and /user-metrics/api.md. -->

# 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](/user-metrics/event-design): 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.

```sh
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:

```json
{"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.**

```json
{
  "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:

```json
{"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.

## Consent, verification and daily charts

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](/user-metrics.md) 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.
