# Security and limits


This page is the reference for how OpenPush authenticates callers, what each credential can do, and every ceiling the server actually enforces. It is deliberately specific about what does *not* exist — an assumed rate limit or an assumed idempotency guarantee is more dangerous than a documented absence.

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

## The two key kinds

Every app has exactly two credentials, plus a legacy alias kept for migration.

| Kind | Header | What it can do | Where it belongs |
|---|---|---|---|
| **REST** | `X-OP-API-Key` | Full admin of **one** app: send messages, read the audience, manage segments, templates, journeys, dynamic content, settings, export, and rotate keys | Your server, your secret manager. **Never in an app binary.** |
| **SDK** | `X-OP-SDK-Key` | Ingest only: register a device, post delivery receipts, post custom events, register Live Activity tokens | **Inside your app binary, by design.** |
| `legacy` | `X-OP-API-Key` | Treated identically to a REST key | Migration compatibility |

The two kinds are enforced at lookup, not by convention. **An SDK key can never satisfy an admin route, and a REST key can never satisfy an ingest route.** There is no route that accepts either.

### Key hygiene

- **The SDK key is public.** It ships inside a binary anyone can unpack, and that is intentional — it can only write ingest data for the app it belongs to. Do not treat leaking it as an incident, and do not build a scheme to hide it.
- **The REST key is a full-admin credential for its app.** It can read your whole audience, export full push tokens, and send to everyone. Keep it server-side, in a secret store, out of source control and out of client-side config.
- One consequence worth planning around: the [custom events](events.md) route is an ingest route, so writing events from your backend means putting the SDK key server-side too. That is fine. It does not mean the SDK key gained privileges.
- Full push tokens appear in exactly one place in the API — the NDJSON export. Treat an export file as a credential.

### Scoping

Per-app keys are looked up against the app id **in the request path**. A REST key for app A cannot read or write app B, and since every app belongs to exactly one workspace, it cannot cross a workspace boundary either.

REST keys are **not role-scoped**. The `viewer` / `manager` / `admin` roles apply to console sessions only; an API key is full admin of its single app or it is nothing.

**Multiple REST keys are supported and are interchangeable while live.** Identity Verification checks a supplied signature against every active REST key, which is what makes rotation without a flag day possible.

### Constant-time comparison

All key comparison uses a constant-time equality check. Neither the length of a submitted key nor the length of its matching prefix is observable by timing, so a submitted key leaks nothing about the real one. This is a small thing, but it is the kind of small thing that is easy to get wrong and worth knowing is right.

## Rotation, disabling, and deletion

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2b/keys/rest/rotate \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

```json
{"app": "app_3f9c2b", "kind": "rest", "key": "<redacted-rest-key>"}
```

`kind` must be one of `rest`, `sdk`, or `legacy`.

> **Warning.** Rotation **replaces the key in place**. The old key stops authenticating on the very next request, everywhere. **There is no grace window on this route** — no overlap period, no dual-validity, no deprecation timer. Deploy the new key before you rotate, or plan for the gap.

`GET /v1/apps/{app_id}/keys` lists the current keys by kind.

### Disable versus delete

A key row can be **disabled** rather than removed. A disabled key keeps its row — it stays visible and can be re-enabled — and stops authenticating from the next request onward.

Disabling is preferable to rotating when you are responding to a suspected leak and want the ability to undo, or when you want the record of the key to survive. Rotating destroys the old value; disabling keeps it.

**Disabling is a console action.** There is no `/v1` route to disable a key. The API can rotate; the console can rotate and disable.

Rotating the **SDK key** is the disruptive one: every installed copy of your app carries the old value, so a rotation blocks device registrations and receipts until an app update ships. Rotate the SDK key only when you have to, and plan the release first.

## The platform-level keys, and why you do not have one

Beyond the two per-app credentials, OpenPush carries two environment-level keys that match on **every** app on the platform. They are held by the OpenPush team and are not issued to customers; they are described here because they explain two behaviours you can observe from the outside.

| Variable | Effect |
|---|---|
| `OP_API_KEY` | Admin of every app on the platform |
| `OP_SDK_KEY` | May register a device on every app, **and auto-provision an app that does not yet exist** |

These cross workspace boundaries by design, which is exactly why they are not a customer credential. The consequence for you is that **two routes have deliberately no per-app equivalent**: listing every app, and creating an app. Both are console actions for you — see [apps, keys and settings](../api-handbook/01-apps-keys-settings.md).

### The switch that closes them

The platform can be run with `OP_GLOBAL_KEYS=off`, which makes the platform-level keys authenticate nothing on `/v1` — every request then has to present a per-app key. That setting is the OpenPush team's, not yours; the reason it matters to you is that while it is in force, the two platform-only routes above answer `401` over the API and the console is the only way in.

> **The caveat, stated plainly.** `OP_GLOBAL_KEYS=off` closes the **`/v1` API** only. `OP_API_KEY` continues to work as the console break-glass password, so it stays a root credential of the platform regardless of the setting. Nothing about either value is under a customer's control, and neither ever appears in a customer's key list.

### Boot safety

OpenPush **refuses to start** if `OP_API_KEY` or `OP_SDK_KEY` are still the development defaults, outside dry-run mode. Nothing reaches production with default credentials by accident.

## Identity Verification

An optional per-app setting. When it is on, a device registration that claims an `external_id`, `tags`, or `aliases` must also present:

```
external_id_auth_hash = HMAC-SHA256(external_id, key = any active REST API key)
```

computed **on your server** and handed to the client. This stops a hostile client from claiming another user's external ID and reading or writing their identity.

Two behaviours that differ from what you might expect:

- The hash is validated against **every active REST key**, so you can rotate keys without invalidating hashes already handed out under the previous one.
- **A failed check is a downgrade, not a rejection.** The identity fields are stripped, the device still registers anonymously, push delivery is unaffected, and the response carries an `identity_rejected` note. A signing bug costs you identity resolution, not the user's notifications.

Turn it on in app settings; details of the registration payload are in [Users and subscriptions](users-and-subscriptions.md).

## Body size limits

A security middleware runs on **every** route and enforces the body ceilings, along with CSRF protection for console forms and the standard security response headers.

| Limit | Default | Applies to |
|---|---|---|
| Maximum request body | **8 MB** | Every route except the CSV import path |
| CSV import upload | **150 MB** | Paths ending exactly `/import` |

> **Note.** The 150 MB allowance is matched against paths ending exactly `/import`. The NDJSON restore route gets the ordinary 8 MB envelope, so a large archive has to be split, or restored through the CSV path instead — the body limit is a platform setting and not one an app can raise. See [Import and export](import-export.md).

## Rate limits

**There are exactly two rate limits on the public API.** Both are per app.

| Scope | Limit | Response |
|---|---|---|
| `POST /v1/apps/{app_id}/events` | **500 events per 5-second window** | `429` with `Retry-After: 5` and a body reporting `rate-limited` |
| Live Activity send routes | **60 requests per 60-second window** | `429` with `Retry-After: 60` |

> **No other route is rate limited.** Not `POST /v1/apps/{app}/messages`. Not `POST /v1/ingest`. Not subscription registration, not session recording, not send-test, not any segment, template, dynamic-content, journey, import, or export route, and not any list route. If you are building a client with backoff, build it around timeouts and `5xx` responses, not around a `429` you will not receive.

Two further limits exist on **console-only surfaces** (sign-in attempts per IP, and the console copy assistant per app). They never apply to API traffic.

### The replica caveat

Every rate limit in OpenPush is a **process-local, in-memory sliding window**. With N replicas behind a load balancer you effectively get N times the stated limit, and a client can be throttled by one replica and not another. Do not treat these numbers as a hard global guarantee in either direction.

### What bounds the API instead

Size and page ceilings, not request counts:

- The 8 MB body limit above.
- Every `limit=` query parameter is clamped to a **maximum page size of 1000**.
- Per-feature caps documented on their own pages: 50 [events](events.md) per request, 200 nodes per [journey](journeys.md), 2000 devices per [Live Activity](live-activities.md) send.

The `429` responses you may see in delivery logs originating from APNs and FCM are **inbound provider** responses driving OpenPush's own retry and backoff. They are not limits OpenPush imposes on you.

## Idempotency

`POST /v1/apps/{app_id}/messages` accepts an `Idempotency-Key` header of 16–128
characters. The same key and body replay the original result for 24 hours without
another send; a different body with that key returns `409`. Supply a fresh key for
each intended message, especially when your infrastructure retries timeouts.

Live Activity **start** uses a separate `idempotency_key` UUID in the request body,
with a 30-day replay window. Its response uses `Idempotent-Replayed: true` on a
replay. Do not apply either retry contract to other routes without checking that
route's documentation.

## Delivery guards: quiet hours and frequency capping

Two per-app settings sit between a send and the provider. They are worth reading as security-adjacent, because they are the guardrails that stop a bad send from being a user-visible incident.

| Setting | Default | Meaning |
|---|---|---|
| `quiet_enabled` | **off** | Whether quiet hours apply |
| `quiet_start` | `08:00` | Start of the **allowed** window, device-local |
| `quiet_end` | `21:00` | End of the allowed window |
| `freq_cap` | **10** | Maximum provider-accepted sends per device per window. **On by default.** `0` disables it. |
| `freq_window_h` | `24` | Cap window, in hours |

Semantics that matter:

- **Quiet hours store an *allowed* interval, not a muted one.** `08:00`–`21:00` means "may send between 8am and 9pm local", not "stay quiet then". Setting start equal to end means always allowed. The window may wrap midnight.
- A device inside quiet hours is **`Held`** — waiting, not dropped. It is released when the window opens.
- A device over the frequency cap is **`Capped`** — suppressed for that message.
- **Only provider-accepted sends consume the cap.** A failed attempt never suppresses a user who received nothing.
- **Test devices bypass both guards** on every message, including ordinary campaigns they merely happen to be in. Bear that in mind when a test device sees something the audience did not.
- Per-user delivery timing is **composed with** the allowed window rather than bypassing it. See [Best-hour delivery](best-hour-delivery.md).

`Capped` and `Held` appear as their own counters in the message report, so a suppressed send is visible rather than silently missing.

## Media limits and retention

Uploaded images are handled with their own budgets and a reference-aware retention policy.

> **Note.** **Media upload is a console action.** There is no `/v1` media route. From the API, supply an HTTPS image URL directly on the message or template.

| Limit | Value |
|---|---|
| Upload size cap | **5 MB** default, configurable within 64 KB – 8 MB |
| `image` kind | 2000 px maximum edge, 300 px minimum width, roughly 1 MB output |
| `icon` kind | 512 px maximum edge, 64 px minimum width, roughly 200 KB output |
| Accepted input | JPEG, PNG, GIF, WEBP |
| Output | PNG (alpha preserved), JPEG, or GIF. Animated GIFs pass through untouched. |
| Decompression guard | 40 megapixels |
| `image_url` field | Maximum 2048 characters; must be `https://` with no whitespace anywhere |

Uploads are deduplicated by hash of the **processed** bytes, per app and kind, so re-uploading the same asset does not consume storage twice.

Media storage has three possible backends — off, local disk, or S3-compatible — chosen by the OpenPush team rather than per app, and **the shipped default is off**. With media off, uploads answer with a message telling you to paste an HTTPS URL instead, and URL-paste continues to work normally. Write your integration so the paste path always works; treat upload as the path that may or may not be enabled, and check `GET /healthz` rather than assuming.

### Retention windows

Retention is **reference-aware**, not a blind bucket lifecycle rule. Nothing is deleted while something points at it.

| Category | Retention |
|---|---|
| Referenced by a template image, an app icon, or a **non-terminal** message (draft, scheduled, in flight) | **Protected indefinitely** |
| Referenced only by messages in a terminal state (delivered, failed, cancelled) | **90 days** after that point, configurable; `0` means never expire |
| Freshly uploaded and not yet referenced | **24-hour** grace window, configurable |

Android `large_icon` and `big_picture` URLs buried inside a message's platform overrides are parsed and protected too. The cleanup pass does nothing at all when the media backend is off.

**Media bytes are deliberately excluded from exports** — metadata without bytes would describe an archive that cannot restore. Keep your own copies, or host images externally. See [Import and export](import-export.md).

## Data collection and privacy controls

Three categories of subscriber data are gated by per-app switches: **advertising id**, **location** (latitude/longitude), and **email**.

- Values for a category that is off are **dropped, not rejected**. A `400` would break every device on a released binary the moment an administrator flipped a switch. The dropped category names come back in a `not_collected` field on the registration response and in import summaries.
- **Turning a switch off also erases the already-stored data for that category**, and the settings response reports how many rows were purged. This is not a soft flag — treat it as a deletion.

**IP storage** is a server-wide setting with three modes: `full` (the default), `truncated` (IPv4 last octet zeroed, IPv6 reduced to the /48), and `off` (nothing stored). The current mode is reported on the app settings response.

## Error responses

Unhandled exceptions never leak internals. API routes return a JSON body carrying a short random error id, the traceback is logged against that id server-side, and nothing about the internal structure of the server reaches the caller. When you report a problem, quote the error id.

Ordinary errors return `{"detail": "<message>"}`. [Journey](journeys.md) routes and the compat [Live Activity](live-activities.md) routes use an `{"errors": [...]}` envelope instead.

## FAQ

**Is it safe that the SDK key is in my app binary?**
Yes — that is what it is for. It can only write ingest data for its own app. The credential you must protect is the REST key.

**Can I rotate a key with an overlap period?**
Not through the API. Rotation is immediate and in place. If you need continuity, deploy the new key first, or disable the old key from the console instead of rotating, so you can re-enable it.

**Will I get a 429 if I send too many campaigns?**
No. The send route is not rate limited. What bounds you is the 8 MB body limit and your provider's own throughput.

**How do I make a retried send safe?**
Dedupe before you call. Idempotency exists only on the Live Activity start route, as a body field.

**What does turning off a collection switch do to existing data?**
It erases it. The settings response tells you how many rows were purged.

**What does `OP_GLOBAL_KEYS=off` do?**
It closes the platform-level keys on the `/v1` API. They still work as the console break-glass password. It is a platform setting held by the OpenPush team, not something you configure — and no customer key is affected either way.

## Related

- [Users and subscriptions](users-and-subscriptions.md)
- [Sending messages](sending-messages.md)
- [Events](events.md)
- [Live Activities](live-activities.md)
- [Import and export](import-export.md)
- [Best-hour delivery](best-hour-delivery.md)
