Events and ingest
Two ingest routes record application events and delivery receipts:
- Custom events —
POST /v1/apps/{app_id}/eventsrecords something a person did, so a journey can react to it. - Delivery receipts —
POST /v1/ingestreports 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 sendexternal_idand the two disagree, the item is refused asidentity-mismatch.external_idalone — 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
Code
Example response
Code
| 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:
Code
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.
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.
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
Code
Example response
Code
| 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
typethat 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.
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/ingestis 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 — auth model, error convention, the real rate limits
- Subscriptions and users — registering the device an event resolves to
- Messages — where
message_idcomes from and where the funnel is read - Journeys — the only consumer of custom events
- Events — naming, taxonomy and what events are good for
- Import and export — what the export does and does not contain