# Apps, keys and settings


An **app** is the top-level container in OpenPush. Every subscription, message, segment,
template and journey belongs to exactly one app, and every per-app API key is scoped to
one. This page covers creating and listing apps, reading and rotating their keys, and
reading and updating their settings.

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

## Apps

App ids are slugs you choose: lowercase, no surrounding whitespace. They appear in every
API path, so pick something stable (`runner-club`, not `runner-club-v2-final`).

The two routes in this section are the only ones on `/v1` that are **not** scoped to a
single app, and both therefore require the platform-level key (`OP_API_KEY`), which is
held by the OpenPush team and is not issued to customers. A per-app REST key is refused
with `401 "bad X-OP-API-Key (org routes need the global key)"`. There is deliberately no
workspace-scoped variant: a per-app key knows one app, and listing or creating a
workspace's apps is a console operation that resolves the workspace from the signed-in
session. **In practice: create and list your apps in the console.** They are documented
here so the `401` is not a mystery.

### `GET /v1/apps`

Lists every app on the platform, across every workspace.

**Auth:** `X-OP-API-Key` — **platform-level key only; not reachable with a customer key**.

**Query parameters:** none.

```bash
curl https://app.openpush.ai/v1/apps \
  -H "X-OP-API-Key: $OP_API_KEY"
```

```json
{
  "apps": [
    {
      "id": "runner-club",
      "name": "Runner Club",
      "created_at": 1756512000.0,
      "org_id": "org_5c8a1e07b93f",
      "icon_url": null,
      "tile_color": null,
      "setup_step": "done",
      "subscribed": 128413,
      "ctr": "4.1%",
      "mau": 96000,
      "mau_exact": 95871,
      "mau_window_days": 30,
      "mau_counted_at": 1756598400.0
    }
  ],
  "mau_definition": "Monthly active users …"
}
```

| Field | Type | Description |
|---|---|---|
| `apps[]` | array | One object per app: the stored app row plus the computed fields below |
| `apps[].id` | string | The app slug used in every path |
| `apps[].name` | string | Display name |
| `apps[].subscribed` | int | Count of currently **sendable** subscriptions (status above zero and not retired) |
| `apps[].ctr` | string | Lifetime click-through rate, preformatted |
| `apps[].mau` | int | Monthly active users, rounded |
| `apps[].mau_exact` | int | The unrounded figure, so you can reconcile it against your own query |
| `apps[].mau_window_days` | int | Length of the counting window |
| `apps[].mau_counted_at` | number | Epoch seconds when the figure was computed |
| `mau_definition` | string | Plain-language statement of exactly what MAU counts |

**Errors**

| Status | Body | Cause |
|---|---|---|
| `401` | `bad X-OP-API-Key (org routes need the global key)` | A per-app REST key was used, or `OP_GLOBAL_KEYS=off` |

### `POST /v1/apps`

Creates an app.

**Auth:** `X-OP-API-Key` — **platform-level key only; not reachable with a customer key**. Use the console.

**Body**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes (or `slug`) | The app slug. Lowercased and trimmed before use |
| `slug` | string | yes (or `id`) | Accepted as an alias for `id` |
| `name` | string | no | Display name. Defaults to the slug with hyphens replaced by spaces and title-cased |

```bash
curl -X POST https://app.openpush.ai/v1/apps \
  -H "X-OP-API-Key: $OP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id":"runner-club","name":"Runner Club"}'
```

```json
{
  "id": "runner-club",
  "name": "Runner Club",
  "created_at": 1756512000.0,
  "org_id": "org_5c8a1e07b93f",
  "icon_url": null,
  "tile_color": null
}
```

Creating an app also creates, in the same call:

- a settings row carrying the defaults documented under [Settings](#settings)
- the app's default segments, including `Total Subscriptions`
- initial API keys — record secret values when created or rotated; later list
  reads contain metadata only

**Where the app lands.** A REST call carries no console session and therefore no
workspace, so a new app is created in the **default workspace**. Creating an app inside a
specific workspace is a console action, where the workspace comes from your membership.

**Re-creating an existing app is not an error.** If the slug already exists, the existing
app row is returned unchanged — no `409`, and nothing about the app is overwritten. Treat
this route as "ensure this app exists".

**Errors**

| Status | Body | Cause |
|---|---|---|
| `400` | `id (slug) required` | Neither `id` nor `slug` was supplied, or both were blank |
| `401` | `bad X-OP-API-Key (org routes need the global key)` | A per-app REST key was used, or `OP_GLOBAL_KEYS=off` |

## Keys

An app can hold multiple REST and SDK keys. A REST key may have `full` or
`read` scope; only full-scope keys can change resources or list key metadata.
SDK keys are ingest-only. Key secrets appear once in create and rotation
responses.

### `GET /v1/apps/{app_id}/keys`

Lists key metadata, including kind, scope, enabled state, expiry and allowed IP
ranges. Secret values are omitted by default. Through **2026-10-23 UTC**,
`?include_values=true` is available for clients transitioning from older key
listing behavior. On 2026-10-24 UTC it returns `410`.

```bash
curl https://app.openpush.ai/v1/apps/runner-club/keys \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

```json
{"keys":[{"id":"key_3e6b91c0d47a","app":"runner-club","kind":"rest",
  "name":"backend","scope":"full","allowed_ips":[],"expires_at":null}]}
```

### Create and update keys

`POST /v1/apps/{app_id}/keys` accepts `kind: "rest" | "sdk"`, an optional
`name`, a REST `scope: "full" | "read"`, and up to 16 `allowed_ips` CIDR
ranges. It returns the secret once. `PATCH /keys/{key_id}` changes `name`,
`scope`, `enabled`, or `allowed_ips`; `DELETE /keys/{key_id}` revokes it.
The last active full administrative key cannot be disabled or deleted.

IP restrictions use the request peer address. A forwarded address is honored
only when the immediate peer is in the server's trusted proxy ranges.

### `POST /v1/apps/{app_id}/keys/{key_id}/rotate`

The path accepts a key ID, or a kind for the most recently created active key
of that kind. Rotation returns a new secret once. The old REST secret works for
24 hours; the old SDK secret works for seven days. The response includes
`old_key_expires_at`.

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

```json
{"app":"runner-club","id":"key_29b400000001","kind":"rest",
 "key":"<new-secret>","shown_once":true,"old_key_expires_at":1790342400.0}
```

### App metadata

`GET /v1/apps/{app_id}` returns the app's ID, name, icon URL, tile color,
store URL and creation time. `PATCH /v1/apps/{app_id}` accepts `name`,
`icon_url`, `tile_color` or `play_store_url`. It does not change the app
slug or platform credentials.

## Settings

App settings hold the delivery guards (quiet hours and frequency capping), the three
optional data-collection switches, and the identity-verification flag. Reading them also
reports per-app platform readiness, which is the number you want when you are debugging
"why did nothing send".

### `GET /v1/apps/{app_id}/settings`

**Auth:** `X-OP-API-Key` (this app's REST key, or the global key).

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `app_id` | string | yes | The app slug |

```bash
curl https://app.openpush.ai/v1/apps/runner-club/settings \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

```json
{
  "app": "runner-club",
  "quiet_start": "08:00",
  "quiet_end": "21:00",
  "quiet_enabled": 0,
  "freq_cap": 10,
  "freq_window_h": 24,
  "collect_ad_id": 0,
  "collect_location": 0,
  "collect_email": 0,
  "identity_verification": 0,
  "android_channels": null,
  "default_tz": null,
  "platforms": {
    "fcm": true,
    "apns": true,
    "web": false,
    "dry_run": false,
    "fcm_source": "app",
    "apns_source": "app"
  },
  "collection": {"ad_id": false, "location": false, "email": false},
  "collected": {"ad_id": 0, "location": 0, "email": 0},
  "ip_storage": "full"
}
```

**Delivery guards**

| Field | Type | Default | Description |
|---|---|---|---|
| `quiet_enabled` | int (0/1) | `0` — off | Whether quiet hours apply to this app |
| `quiet_start` | string `HH:MM` | `"08:00"` | Start of the **allowed** window, in each device's local time |
| `quiet_end` | string `HH:MM` | `"21:00"` | End of the allowed window |
| `freq_cap` | int | `10` | Maximum provider-accepted sends per device per window. **On by default.** `0` disables the cap |
| `freq_window_h` | int | `24` | Length of the cap window, in hours |

The quiet window stores the interval during which sending is **allowed**, not the interval
during which it is muted. `quiet_start == quiet_end` means always allowed, and the window
may wrap past midnight. A device inside quiet hours is **held**, not dropped — it is
released when its window opens. Full semantics, including how they compose with per-user
delivery timing: [Messages](02-messages.md).

**Data-collection switches**

| Field | Type | Default | Description |
|---|---|---|---|
| `collect_ad_id` | int (0/1) | `0` | Whether advertising identifiers are stored |
| `collect_location` | int (0/1) | `0` | Whether latitude/longitude are stored |
| `collect_email` | int (0/1) | `0` | Whether email addresses are stored |
| `collection` | object | — | The same three switches as booleans, keyed `ad_id`, `location`, `email` |
| `collected` | object | — | How many rows currently hold a value for each category |

`collected` answers the question `collection` cannot: "off" and "off, and there are 12,400
of them already in the table" are different facts.

**Other fields**

| Field | Type | Description |
|---|---|---|
| `identity_verification` | int (0/1) | Whether identity verification is required on registration. Off by default. **Changing this is a console action — the PATCH route below does not accept it** |
| `android_channels` | string \| null | JSON list of registered Android notification channels — a vocabulary registry the composer and `op_android_channel` draw from. Console-managed |
| `default_tz` | string \| null | Present in the stored row and in exports. It is not currently consulted by the send pipeline; a device that reports no usable timezone is treated as UTC |
| `ip_storage` | string | `full`, `truncated` or `off`. **Server-wide**, set by `OP_IP_STORAGE`, not per app |
| `platforms` | object | Per-app delivery readiness — see below |

**`platforms`**

| Field | Type | Description |
|---|---|---|
| `fcm` | bool | FCM can deliver for this app |
| `apns` | bool | APNs can deliver for this app. This is a real check: the `.p8` is parsed and a provider token is actually signed |
| `web` | bool | A web push credential is stored for this app |
| `dry_run` | bool | The server is running without real provider delivery |
| `fcm_source` / `apns_source` | string | Where the credential came from — the app's own record, an environment fallback, `none`, or `unreadable` |
| `fcm_error` | string | Present only when FCM is not ready and the server is not in dry-run: why |
| `apns_error` | string | Present only when APNs is not ready: why. Reported even under dry-run, because "no iOS credential at all" is a normal state that deserves a reason |

This is the per-app answer. `GET /healthz` gives the platform-wide one — see
[API overview](00-overview.md).

**Errors**

| Status | Body | Cause |
|---|---|---|
| `401` | `bad X-OP-API-Key` | Missing, wrong, disabled, or wrong-app key |

### `PATCH /v1/apps/{app_id}/settings`

Updates the delivery guards and the collection switches. Send only the fields you want to
change.

**Auth:** `X-OP-API-Key` (this app's REST key, or the global key).

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `app_id` | string | yes | The app slug |

**Body**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `quiet_enabled` | int (0/1) | no | Turn quiet hours on or off |
| `quiet_start` | string `HH:MM` | no | Start of the allowed window |
| `quiet_end` | string `HH:MM` | no | End of the allowed window |
| `freq_cap` | int | no | Provider-accepted sends allowed per device per window. `0` disables |
| `freq_window_h` | int | no | Cap window length in hours |
| `collect_ad_id` | bool | no | Advertising-identifier collection (flat spelling) |
| `collect_location` | bool | no | Location collection (flat spelling) |
| `collect_email` | bool | no | Email collection (flat spelling) |
| `collection` | object | no | The same three switches nested: `{"ad_id": true, "location": false, "email": false}` |

Collection switches are accepted **either** flat or nested, because a console form posts
one shape and an integration usually posts back the object it just read. Only the
switches you actually name are touched; the others are left alone.

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/runner-club/settings \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"quiet_enabled":1,"quiet_start":"09:00","quiet_end":"20:30","freq_cap":4,"collection":{"ad_id":false}}'
```

```json
{
  "app": "runner-club",
  "quiet_start": "09:00",
  "quiet_end": "20:30",
  "quiet_enabled": 1,
  "freq_cap": 4,
  "freq_window_h": 24,
  "collect_ad_id": 0,
  "collect_location": 0,
  "collect_email": 0,
  "identity_verification": 0,
  "android_channels": null,
  "default_tz": null,
  "collection": {"ad_id": false, "location": false, "email": false},
  "collected": {"ad_id": 0, "location": 0, "email": 0},
  "purged": {"ad_id": 12400},
  "ip_storage": "full"
}
```

**Turning a collection switch off erases the data already stored for that category.**
This is not a "stop writing from now on" toggle — it deletes. The `purged` object in the
response reports how many rows were cleared per category; it is empty when nothing went
from on to off in this request. There is no undo.

The response is the full settings object, in the same shape as `GET`, plus `purged`.

**Errors**

| Status | Body | Cause |
|---|---|---|
| `400` | `nothing to update` | The body named none of the accepted fields |
| `401` | `bad X-OP-API-Key` | Missing, wrong, disabled, or wrong-app key |

## Limits

- `GET /v1/apps` and `POST /v1/apps` accept **only** the platform-level key, which
  customers do not hold; when the platform runs with `OP_GLOBAL_KEYS=off` they are
  unreachable over the API at all. Either way, listing and creating apps is a console
  operation for you.
- There is **no** `DELETE /v1/apps/{app_id}`. Deleting an app is not an API operation.
- `PATCH /settings` accepts the delivery guards and the collection switches only.
  `identity_verification`, `android_channels` and `default_tz` are read-only over the API
  and are managed in the console.
- Individual key routes can create, disable, delete and rotate keys.
- `ip_storage` is server-wide configuration, reported per app for convenience. You cannot
  set it per app.
- Rotation has a 24-hour REST or seven-day SDK overlap window.

## FAQ

**How do I create an app inside a specific workspace?**
Use the console. A REST call has no session, so `POST /v1/apps` always lands the app in
the default workspace.

**Is `POST /v1/apps` safe to run repeatedly in a provisioning script?**
Yes. An existing slug returns the existing app row unchanged, so the call is effectively
"ensure this app exists". It will not reset keys or settings.

**I rotated the SDK key. When should devices adopt the new value?**
Shipped binaries can keep ingesting with the previous value for seven days. Ship a build
with the new value during that overlap; devices still receive pushes to stored tokens.

**Why is `freq_cap` on by default but `quiet_enabled` off?**
A cap protects users from an accident with no configuration; the value is a sensible
ceiling rather than a policy. Quiet hours are a policy — the right window depends on your
audience — so they stay off until you set them.

**How do I turn identity verification on?**
In the console. The API reports the flag but does not accept a change to it, deliberately:
flipping it on for a fleet that has not yet shipped the hashing code silently downgrades
every subsequent registration.

## Related

- [API overview](00-overview.md)
- [Messages](02-messages.md)
- [Subscriptions and users](03-subscriptions-users.md)
- [Security and limits](../guides/security-and-limits.md)
- [APNs setup](../guides/platform-setup-apns.md)
- [FCM setup](../guides/platform-setup-fcm.md)
