# Events


An event is a named record that something happened to one of your users — `cart_abandoned`, `level_up`, `subscription_started` — optionally carrying a small bag of properties. Events are written into OpenPush from your app through an SDK, or from your backend over REST, and they can trigger or exit [journeys](journeys.md), appear in the event explorer, and measure [conversion metrics](data-connections.md#events--conversions).

Choose a focused event taxonomy around the player actions you need to act on or measure. See [What events feed](#what-events-feed).

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

## When to use

- You want a journey to start the moment a user does something, rather than on the next segment sweep.
- You want a journey to release a user as soon as they convert, so they stop receiving the rest of the flow.
- You want a lightweight record of user actions you can browse in the console.

If what you actually want is "target everyone who did X in the last 7 days", events will not get you there — that is not expressible as a segment filter. Write a [tag](users-and-subscriptions.md) instead, then target it with a [segment](segments.md) or an inline message filter.

## Standard versus custom events

**There are no standard events.** OpenPush ships no reserved-name table, no allowlist, and no built-in event vocabulary. Every event is a custom event and every name is yours to choose. The only constraint is the charset.

This has a practical consequence: nothing is validated against a schema, so a typo produces a new event name rather than an error. Fix your names in one place in your app code and treat the event catalogue in the console as the record of what you have actually been sending.

### Name rules

| Rule | Value |
|---|---|
| Allowed characters | `a`–`z`, `A`–`Z`, `0`–`9`, underscore, hyphen, period, space |
| Maximum length | 128 characters |
| Case | Preserved and significant — `Level_Up` and `level_up` are different events |

Journey event triggers apply the identical name rule, so a name that is legal to send is legal to trigger on.

## Sending events

### From an SDK

All three SDKs expose the same call, batch it, and retry it. Writes coalesce into a 300 ms window and go up in batches of at most 50.

```kotlin
// Android
OpenPush.trackEvent("level_up", mapOf("level" to 12, "world" to "frost"))
```

```swift
// iOS
OpenPush.trackEvent("level_up", properties: ["level": 12, "world": "frost"])
```

```csharp
// Unity
OpenPush.TrackEvent("level_up", new Dictionary<string, object> {
    {"level", 12}, {"world", "frost"}
});
```

The SDKs validate the name and property size locally before they queue anything, so a bad name fails fast in development rather than silently dropping in production.

If you are migrating, the OneSignal outcome methods on all three SDKs forward to real events: `addOutcome(name)` sends the event, `addUniqueOutcome(name)` sends it with `{"unique": true}`, and `addOutcomeWithValue(name, value)` sends it with `{"value": value}`.

### From REST

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2b/events \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {"name": "cart_abandoned",
       "external_id": "user_88214",
       "properties": {"value_usd": 62.5, "items": 3},
       "timestamp": 1735689600.0},
      {"name": "level_up",
       "subscription_id": "sub_01hq8n4t2v",
       "properties": {"level": 12}}
    ]
  }'
```

```json
{"accepted": 2, "dropped": 0, "dropped_reasons": {}}
```

> **Note.** The events route authenticates with the **SDK key**, not the REST key — it is an ingest route. If you are calling it from your backend, that means shipping the SDK key server-side too. The SDK key is designed to be public (it ships inside your app binary), so this is not a privilege leak, but it does mean a REST key alone cannot write events.

There is no read route for events on `/v1`. The console has a custom-events browser; the API does not expose one.

## Payload rules

| Field | Type | Required | Rules |
|---|---|---|---|
| `name` | string | yes | Charset and length above |
| `subscription_id` | string | conditional | One of `subscription_id` / `external_id` is required |
| `external_id` | string | conditional | As above |
| `properties` | object | no | See below |
| `timestamp` | number | no | Unix seconds as a float; defaults to now |

### Properties

- Must be a **JSON object**. An array or a scalar is rejected with `properties-must-be-object`.
- Must be JSON-serializable end to end (`properties-not-json` otherwise).
- The serialized form must be **≤2048 bytes of UTF-8** (`properties-too-large`).
- **Values are unrestricted in type.** Nested objects and arrays are accepted. There is no per-key count limit and no key-length limit at ingestion.

The 2048-byte ceiling is the only real constraint, and it counts the whole serialized object. Keep properties to the handful of fields a journey condition might read; events are not an analytics warehouse.

### Timestamps

- Absent or empty string → now.
- Booleans are explicitly rejected.
- Anything non-numeric, `NaN`, or infinite → `invalid-timestamp`.
- **Any finite value is accepted.** There is no bound on how far in the past or the future you may place an event.

That last point matters for backfills. See [What events feed](#what-events-feed) — an event older than 24 hours is stored but will not fire a journey.

## Identity resolution

Every event must resolve to a user. The rules, in order:

| Supplied | Behaviour |
|---|---|
| `subscription_id` | Must exist on this app **and** already be linked to a user, else `subscription-not-found`. |
| `subscription_id` + `external_id` | Both resolved; if they disagree, `identity-mismatch`. |
| `external_id` only | Looked up among users; not found → `user-not-found`. The event is recorded with a null subscription. |
| Neither | `identity-required`. |

In practice: send `subscription_id` from a device (the SDKs do this for you), and `external_id` from your backend, where you know the user but not which device is relevant.

An event carrying only an `external_id` is attributed to the user, not to a device — which is the right shape for journeys, since journeys enrol users.

## Batching, limits, and partial acceptance

| Limit | Value | Response when exceeded |
|---|---|---|
| Items per request | **50** | `400 "events accepts at most 50 items per request"` |
| Events per app | **500 per 5-second window** | `429` with `Retry-After: 5` |
| Property size | 2048 bytes serialized | Item dropped, counted as `properties-too-large` |

The 500-per-5-seconds limit is one of only two real rate limits in the entire API (the other is on the Live Activity sending routes) — see [Security and limits](security-and-limits.md). It is a process-local sliding window rather than a global one, so the ceiling you actually see scales with however many replicas OpenPush happens to be running — treat 500 per 5 seconds as the floor you can rely on, not the number to aim at.

**Partial acceptance is the rule.** Each item in a batch is recorded independently; a bad item never rejects the batch. The response tells you what happened in aggregate:

```json
{"accepted": 47,
 "dropped": 3,
 "dropped_reasons": {"user-not-found": 2, "invalid-name": 1}}
```

> **Note.** There is no per-index `errors` array. The response tells you *how many* items failed for *which reasons*, but not *which* items. If you need to know exactly which record failed, send smaller batches or key your own retries by reason count.

Common `dropped_reasons` keys: `invalid-name`, `invalid-timestamp`, `properties-must-be-object`, `properties-not-json`, `properties-too-large`, `subscription-not-found`, `user-not-found`, `identity-mismatch`, `identity-required`, and `rate-limited` on a `429`.

## What events feed

Events feed the following parts of OpenPush:

| Destination | Status |
|---|---|
| **Journey triggers and exits** | **Yes.** |
| **Segments** | No. Segment filters have no event field and never read the event store. Behavioural targeting is not expressible as a segment. |
| **Analytics and charts** | The Data explorer charts custom events, and conversion metrics count or sum them. Existing message delivery charts keep their own data sources. |
| **Best-hour delivery** | No. The per-user send-time model trains on registrations, sessions, and first clicks only. |

### How the journey hook works

When an event is recorded, the server hands it to the journey engine — **but only if the event's own timestamp falls within the last 24 hours.** A backfill of last quarter's events is catalogue data: stored, browsable, inert. Plan any migration around that.

The engine then does two things in a fixed order:

1. **Early exit first.** Any active journey whose `early_exit.on_event.name` matches finishes every live run that user has in it.
2. **Entry second.** Any active journey whose `audience.name` matches and whose attribute conditions are satisfied enrols the user.

Because exit runs before entry, a journey that uses the *same* event name for both entry and exit behaves as a deliberate restart: the old run is closed, a new one begins.

The hook is fire-and-forget with respect to ingestion. If the journey engine errors, the event is still recorded and the API still returns success — event ingestion is never rolled back by a journey fault.

### Attribute matching at runtime

A journey event trigger can require attributes. Only the first AND-group is evaluated. `is` and `is_not` are string comparisons; the numeric operators parse both sides as floats and evaluate to false if either side does not parse. There is no date or before/after operator.

## Storage and retention

- Events are stored per app with the user id, subscription id, external id, name, properties, and creation time, indexed for lookup by name and by user over time.
- Each event is committed individually.
- History is unlimited by default. App admins can choose per-event retention in Data → Events & Conversions → Event storage. An hourly cleanup removes older events; saved conversion reports remain.
- Data → Events & Conversions exports the latest 10,000 events as CSV. The general app archive retains its existing export contract. See [Data connections](data-connections.md) for the dedicated Data history export.

## FAQ

**Can I use an event to build a "did X in the last 30 days" audience?**
Not directly. Segments cannot read events. The working pattern is: trigger a journey on the event, and have the journey's `tag` node stamp a tag on the user; then build the segment on the tag.

**Why did my imported historical events not start any journeys?**
Events older than 24 hours are recorded but not handed to the journey engine. Only recent events trigger.

**Do I need a separate key to send events from my server?**
You need the SDK key. The events route is an ingest route and does not accept a REST key.

**How do I see what events I have been sending?**
The console has a custom-events browser showing recent events and the name catalogue. There is no `/v1` read route for events.

**What happens if I exceed 500 events in five seconds?**
The whole request comes back `429` with `Retry-After: 5` and a body reporting `rate-limited`. Nothing in that request is recorded, so retry the batch after the interval.

## Related

- [Journeys](journeys.md)
- [Segments](segments.md)
- [Users and subscriptions](users-and-subscriptions.md)
- [Android SDK](sdk-android.md) · [iOS SDK](sdk-ios.md) · [Unity SDK](sdk-unity.md)
- [Security and limits](security-and-limits.md)
- [Import and export](import-export.md)
