# Events and ingest


Two ingest routes record application events and delivery receipts:

- **Custom events** — `POST /v1/apps/{app_id}/events` records something a person did, so a
  journey can react to it.
- **Delivery receipts** — `POST /v1/ingest` reports what happened to a notification after
  OpenPush handed it to a provider, so the delivery funnel on a message is real rather than
  inferred.

All examples use `https://app.openpush.ai`, the OpenPush API base URL.

**Auth:** `X-OP-SDK-Key` on both routes. A writable `X-OP-API-Key` can also
post custom events from a backend. Delivery receipts remain SDK-key only.

---

## POST /v1/apps/{app_id}/events

Records up to 50 custom events in one request, with per-item partial acceptance.

**Auth:** `X-OP-SDK-Key` or writable `X-OP-API-Key`

### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `app_id` | string | yes | The app these events belong to |

### Body

| Parameter | Type | Required | Description |
|---|---|---|---|
| `events` | array | **yes** | Between 0 and 50 event objects |

### Event object

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `name` | string | **yes** | — | Event name. `A–Z`, `a–z`, `0–9`, `_`, `-`, `.` and spaces; 1–128 characters |
| `properties` | object | no | `{}` | Free-form JSON object. Serialized form must be ≤ 2048 bytes of UTF-8 |
| `timestamp` | number | no | now | Unix seconds. Fractional values are fine |
| `subscription_id` | string | one of these two | — | The subscription that produced the event |
| `external_id` | string | one of these two | — | Your own user id |
| `idempotency_key` | string | no | — | Retry key scoped to the app and owner |

An identical retry returns the stored event with `duplicate: true` and does not
trigger a second journey entry. Reusing the key with different event content
rejects that item. The batch retains `accepted` as the count of accepted items;
`inserted` and `duplicates` separate new writes from replays.

### There are no standard events

Every event is a custom event. There is no allowlist, no reserved-name table, and no set of
built-in names that behave differently. `purchase`, `level_up` and `weekly_boss_defeated` are all
the same kind of thing to the server; the only constraint is the character set and the length.

Pick names and stick to them — nothing normalises `Level Up`, `level_up` and `level.up` into one
event, and journeys match names exactly.

### Properties

`properties` must be an object. Values are unconstrained: strings, numbers, booleans, nested
objects and arrays all round-trip. There is no cap on the number of keys and no cap on an
individual key's length — the only limit is that the **serialized** object must fit in 2048 bytes
of UTF-8.

### Timestamps

`timestamp` is Unix seconds. Absent or an empty string means now. Booleans, non-numeric values,
`NaN` and infinities are refused. Any finite number is accepted, in the past or the future —
there is no bound.

One consequence is worth knowing before you backfill: **an event whose timestamp is more than 24
hours old is catalogue data only.** It is stored, and it can never trigger or exit a journey. A
same-day import behaves like live traffic; a year of history does not fire a year of journeys.

### Identity resolution

Each event must resolve to a user. Supply `subscription_id`, `external_id`, or both:

- **`subscription_id`** — must exist on this app and already be attached to a user. If you also
  send `external_id` and the two disagree, the item is refused as `identity-mismatch`.
- **`external_id` alone** — looked up among the app's users. The event is recorded against that
  user with no subscription attached.
- **Neither** — refused as `identity-required`.

A device that has only just registered already has a user, so the ordinary SDK path (register
first, then send events with the subscription id) always resolves.

### Batching and partial acceptance

Each item is recorded independently. **One bad item never rejects the batch** — it is counted,
its reason is tallied, and the rest are written.

Two conditions do reject the whole request before any item is written: a batch of more than 50
items, and the rate limit.

### Rate limit

**500 events per app per 5-second window.** The window counts the number of *events* in the
requests, not the number of requests. Exceeding it returns `429` with `Retry-After: 5` and no
item in that request is recorded.

The event rate counter is shared by server replicas.

### Example request

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2a/events \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "events": [
          {"name": "level_up",
           "properties": {"level": 12, "class": "ranger"},
           "subscription_id": "sub_01hq7m4c2p"},
          {"name": "purchase",
           "properties": {"sku": "gem_pack_l", "value": 9.99, "currency": "USD"},
           "timestamp": 1738368000.0,
           "external_id": "player-77"},
          {"name": "level_up",
           "external_id": "player-does-not-exist"}
        ]
      }'
```

### Example response

```json
{
  "accepted": 2,
  "inserted": 2,
  "duplicates": 0,
  "dropped": 1,
  "dropped_reasons": {"user-not-found": 1},
  "events": [
    {"id": "evt_01hq7pd3k9", "name": "level_up",
     "created_at": 1738454412.418, "user_id": "usr_01hq7m3x8a"},
    {"id": "evt_01hq7pd3ka", "name": "purchase",
     "created_at": 1738368000.0, "user_id": "usr_01hq7m3x8a"}
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `accepted` | integer | How many items were written |
| `dropped` | integer | How many were refused |
| `dropped_reasons` | object | Reason code → count, over the dropped items |
| `events` | array | One object per accepted item: `id`, `name`, `created_at`, `user_id` |

**There is no per-index error array.** Failures are aggregated into `dropped_reasons` as a
histogram — you learn what went wrong and how often, not which array position. If you need to
attribute a failure to a specific item, send a smaller batch.

### Drop reasons

| Reason | Meaning |
|---|---|
| `event-must-be-object` | The array element was not a JSON object |
| `invalid-name` | Missing `name`, wrong characters, empty, or over 128 characters |
| `properties-must-be-object` | `properties` was an array, string, or other non-object |
| `properties-not-json` | `properties` could not be serialized |
| `properties-too-large` | Serialized `properties` exceeded 2048 bytes of UTF-8 |
| `invalid-timestamp` | A boolean, a non-numeric value, `NaN`, or an infinity |
| `identity-required` | Neither `subscription_id` nor `external_id` was supplied |
| `subscription-not-found` | No such subscription on this app, or it has no user yet |
| `identity-mismatch` | `subscription_id` and `external_id` name different users |
| `user-not-found` | No user on this app holds that `external_id` |
| `invalid-idempotency-key` | The retry key does not meet the event key contract |
| `idempotency-conflict` | The key was already used with different event content |
| `rate-limited` | Only on a `429`, where it always reads `{"rate-limited": 1}` |

### Errors

| Status | Body | Cause |
|---|---|---|
| `400` | `{"detail": "body must be JSON"}` | The request body did not parse |
| `400` | `{"detail": "events must be an array"}` | `events` was absent or not an array |
| `400` | `{"detail": "events accepts at most 50 items per request"}` | More than 50 items |
| `401` | Credential error | Missing, invalid, or wrong-app SDK or REST key |
| `404` | `{"detail": "unknown app '<id>'"}` | No such app |
| `429` | see below | Over 500 events for this app in the current 5-second window |

The `429` body is shaped like a normal result so a client can handle it on one code path:

```json
{"accepted": 0, "dropped": 0, "dropped_reasons": {"rate-limited": 1}}
```

with `Retry-After: 5`. Note that `dropped` is `0` — nothing was even attempted. Re-send the whole
batch after the wait.

### What events feed

Be clear-eyed about this before you build a taxonomy around it:

| Destination | Does it read events? |
|---|---|
| **Journeys** | **Yes.** Event triggers, early-exit conditions and event branches. This is the consumer events exist for |
| **Segments** | No. There is no event field in the segment filter language, so behavioural targeting is not expressible as a segment |
| **Message analytics and charts** | No |
| **Best-hour delivery** | No. It trains on registrations, sessions and first clicks |

After an event is written, journeys are evaluated in one order: **early exit first, then entry.**
That makes an event used as both a journey's entry trigger and its early-exit condition a
deliberate restart. Only events timestamped within the last 24 hours reach that path at all.

If the journey hook raises, the event stays written — ingestion never rolls back because a
journey misbehaved. See [Journeys](07-journeys.md).

### Reading events back

**There is no events read route on `/v1`.** Recent events and a 30-day name catalogue are
visible on the app's custom-events page in the console, and that is the only read surface. Events
are also **not** included in the NDJSON export or the per-entity CSV exports — see
[Import and export](09-import-export.md).

There is no retention or purge job for events either: what you send is kept until you remove it
from the database yourself.

---

## POST /v1/ingest

Reports what happened to a notification on the device. This is what turns the delivery funnel on
a message from "the provider accepted it" into something you can trust.

**Auth:** `X-OP-SDK-Key`

Note the shape: **the app id is not in the path.** Each event names its own `app`, and the SDK
key you present is validated against every distinct app id in the batch. A key that is not valid
for one of them fails the request.

### Body

| Parameter | Type | Required | Description |
|---|---|---|---|
| `events` | array | **yes** | Receipt objects |

### Receipt object

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string | **yes** | `received`, `confirmed`, or `clicked` |
| `app` | string | **yes** | The app id. Your SDK key must be valid for it |
| `message_id` | string | **yes** | The message id carried in the push payload |
| `token` | string | **yes** | The device's push token |
| `surface` | string | no | Free-form label, analytics only |

### The receipt ladder

The three stages are deliberately distinct, and so is the provider stage that precedes them:

| Stage | Who observes it | What it means |
|---|---|---|
| **Provider Accepted** | The server, at send time | APNs or FCM took the message. It is not a delivery |
| **Device Received** (`received`) | Your SDK's push handler | The data payload reached the device |
| **Confirmed Receipt** (`confirmed`) | Your SDK, after display | A notification was actually shown |
| **Clicked** (`clicked`) | Your SDK's click handler | Somebody tapped it |

Each stage is a strictly smaller number than the one above it. Reporting "delivered" as a single
figure is the thing this ladder exists to avoid.

### Example request

```bash
curl -X POST https://app.openpush.ai/v1/ingest \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "events": [
          {"type": "received", "app": "app_3f9c2a",
           "message_id": "msg_01hq7n8v5r", "token": "fN1x8sQ7Tk2:APA91bH…"},
          {"type": "confirmed", "app": "app_3f9c2a",
           "message_id": "msg_01hq7n8v5r", "token": "fN1x8sQ7Tk2:APA91bH…",
           "surface": "ios"},
          {"type": "clicked", "app": "app_3f9c2a",
           "message_id": "msg_01hq7n8v5r", "token": "fN1x8sQ7Tk2:APA91bH…"}
        ]
      }'
```

### Example response

```json
{"accepted": 3, "dropped": 0}
```

| Field | Type | Description |
|---|---|---|
| `accepted` | integer | Receipts that stamped a delivery row |
| `dropped` | integer | Everything else |

### What counts as dropped

`dropped` is not an error signal. A receipt is counted as dropped, with a `200`, when it is:

- not a JSON object, or missing any of `type`, `app`, `message_id`, `token`
- carrying a `type` that is not one of the three
- naming a token this app has no subscription for
- naming a message id that does not exist on this app
- **already stamped** — the stage was recorded by an earlier request

That last case is the common one and it is normal. SDKs retry, and a retried receipt is
deliberately counted rather than treated as a failure.

### Timestamps: first observation wins

Each stage is written with a coalescing update, so **the first observation wins and a replay
cannot move a timestamp.** Reporting `clicked` twice records one click time; the funnel counts
distinct devices, not taps.

A device's **first-ever** click on any message also bumps that user's local-hour activity
histogram with extra weight, which is one of the three signals that train
[Best-hour delivery](../guides/best-hour-delivery.md).

### Errors

| Status | Body | Cause |
|---|---|---|
| `400` | JSON parse failure | The request body did not parse |
| `401` | `{"detail": "bad X-OP-SDK-Key"}` | The key is not valid for one of the app ids in the batch |

Malformed or unknown individual receipts do **not** produce an error status — they land in
`dropped`.

---

## Limits and notes

- **Custom events:** 50 items per request; 500 events per app per 5-second window; 2048 bytes of
  serialized properties per event; 128 characters per event name.
- **`/v1/ingest` is not rate-limited.** Neither is registration or sending. The events route and
  Live Activity migration routes have a separate rate limit.
- Both routes are bounded by the server's request body limit, 8 MB by default.
- Neither route offers idempotency keys. The events route will happily record the same event
  twice if you send it twice; the ingest route is naturally idempotent because the first
  observation of each stage wins.
- Events are **not** exported. Plan your own copy if you need them elsewhere — there is no
  webhook, event stream, or analytics forwarding in OpenPush.
- Receipts only exist for messages OpenPush sent. There is no way to backfill delivery history
  from another platform.

## Related

- [API overview](00-overview.md) — auth model, error convention, the real rate limits
- [Subscriptions and users](03-subscriptions-users.md) — registering the device an event resolves
  to
- [Messages](02-messages.md) — where `message_id` comes from and where the funnel is read
- [Journeys](07-journeys.md) — the only consumer of custom events
- [Events](../guides/events.md) — naming, taxonomy and what events are good for
- [Import and export](09-import-export.md) — what the export does and does not contain
