# Sending messages


A message is one send: content, an audience, and timing. This guide covers composing content,
choosing who gets it, when it goes out, how quiet hours and frequency caps can hold it back, the
per-platform options that change how it looks on the device, and how to read what happened
afterwards.

All examples use `https://app.openpush.ai`, the OpenPush API base URL, and the
app's REST API key in `X-OP-API-Key`.

## When to use this page

Whenever you are building a send from your backend. If you want the fastest possible first push,
start with the [quickstart](quickstart.md). For the exact request and response shapes, see the
[messages API reference](../api-handbook/02-messages.md).

## Prerequisites

- An app with at least one platform credential configured
  ([APNs](platform-setup-apns.md), [FCM](platform-setup-fcm.md)).
- The app's REST API key.
- At least one registered, sendable subscription. An immediate send with an empty audience returns
  `404 no matching subscriptions — did the app register?` rather than quietly succeeding.

## Composing content

The minimum viable message is a title and a body.

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Season 4 launch",
        "title": "Season 4 is live",
        "body": "Three new maps and a ranked reset.",
        "image_url": "https://cdn.acme.example/season4.png",
        "deep_link": "acme://season/4",
        "data": {"season": "4", "cohort": "returning"}
      }'
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes* | Rendered as the notification title. Liquid-enabled. Render cap 512 characters |
| `body` | string | yes* | Liquid-enabled. Render cap 2048 characters |
| `template` / `template_id` | string | no | A template id or name. Anything you also pass in the body wins over the template's value, field by field |
| `image_url` | string | no | Must be `https://` or a media path hosted by OpenPush. Liquid-enabled |
| `deep_link` | string | no | Delivered to the device as a payload key for your app to route on. Liquid-enabled |
| `data` | object | no | Custom payload. Must be a JSON object and must survive FCM's string-only data map |
| `custom_data` | object | no | Message-level variables for Liquid, available as `message.custom_data`. Hard cap 2 KB serialized |
| `name` | string | no | Campaign name in reports. Defaults to the title |
| `created_by` | string | no | Attribution string in reports. Defaults to `"api"` |

\* Required unless a referenced template supplies them. If neither the body nor the resolved
template gives you both a title and a body, the request is
`400 need title+body or a known template`.

> **`vars` does nothing on this route.** It is accepted for backward compatibility but is no longer
> applied as a render pass, because rendering Liquid at compose time would flatten user tags,
> language and dynamic content before the send pipeline ever saw a device. Pass message-level
> variables as `custom_data` instead.

### Action buttons

Add one to three mobile notification buttons with the top-level `actions` field:

```json
{
  "title": "Your ride is here",
  "body": "Marek is outside in a grey Kona.",
  "actions": [
    {"id": "call", "label": "Call driver", "icon": "ic_call"},
    {"id": "later", "label": "Later"}
  ]
}
```

| Action field | Rules |
|---|---|
| `id` | Required and unique, 1–64 characters, `[A-Za-z0-9._-]` only |
| `label` | Required, 1–256 characters. Liquid-enabled; rendered labels are capped at 48 characters |
| `icon` | Optional, ≤256 characters |

Invalid buttons, extra fields, and a list outside the 1–3 range return `400` before the
message is created. Do not put buttons in `data.op_actions`; that is an internal payload
key. The app's click callback receives the selected action ID, while message reports
count button taps without a per-button breakdown. To show labels in each device's
language, add `languages.<code>.action_labels` keyed by the button `id` — see
[per-language content](../api-handbook/02-messages.md#per-language-content). In the composer,
each language tab has a **Button labels** field, which **Translate with AI** fills. For
display integration, see the
[iOS](sdk-ios.md#action-buttons-from-the-extension) and
[Android](sdk-android.md#action-buttons) SDK guides.

### Custom data and reserved keys

Everything in `data` reaches the device. Four keys are reserved and cannot be overridden by your
payload — `title`, `body`, `op_message_id` and `op_app` — and so are the three delivery-option keys
(`op_ttl`, `op_priority`, `op_collapse_id`) whenever you set the matching body field. `op_message_id`
in particular must survive — it is what the SDK posts back to `/v1/ingest`, and without it the
Device Received → Confirmed Receipt → Clicked ladder cannot be attributed.

`data` is validated up front against FCM's string-only data map. Nested objects and arrays are
serialized for you deterministically; anything genuinely un-encodable (NaN, infinity) is
`400 data is not provider-safe JSON: …`.

## Per-language content

Supply translations as a `languages` map and a default:

```json
{
  "title": "Season 4 is live",
  "body": "Three new maps and a ranked reset.",
  "default_language": "en",
  "languages": {
    "en":    {"title": "Season 4 is live",       "body": "Three new maps and a ranked reset."},
    "pt":    {"title": "A 4ª temporada chegou",  "body": "Três mapas novos e reset de ranking."},
    "pt-BR": {"title": "A 4ª temporada chegou",  "body": "Três mapas novos e o ranking zerado."}
  }
}
```

Language codes are normalised: lowercased, underscores turned into hyphens, blanks dropped.

**Selection is exact match first, then the base language.** A device reporting `pt-BR` takes the
`pt-BR` entry; if there were none, it would fall back to `pt`, and only then to the default. A
variant that fills in only one of title or body inherits the other.

The device's language comes from the `language` field it reported at registration. Language
selection happens *before* Liquid rendering, so `{{ }}` expressions resolve inside translated copy
rather than only inside the default.

## Targeting

Three mechanisms, and they compose.

### Segments

```json
{
  "include_segments": ["seg_7b1c9e4a2f60", "seg_0a4c1d92be77"],
  "exclude_segments": ["seg_3f81ce02a4d5"]
}
```

Includes are OR'd together; excludes are subtracted. An empty `include_segments` means every
sendable subscription in the app. An unknown segment id is a `400`. See [segments](segments.md)
for the filter language and worked recipes.

### Direct targeting

For transactional sends, `target` addresses one person or device without a segment:

```json
{"target": {"external_id": "user_8412"}}
```

| `target` field | Matches |
|---|---|
| `external_id` | The user's external id, exactly |
| `token` | One device push token, exactly |
| `subscription_id` | One subscription id, exactly |
| `alias` | `{"label": "crm_id", "id": "C-99213"}` — **both** keys required |
| `platform` or `platforms` | `ios`, `android`, `web`; a string or an array |

Two deliberate strictnesses worth knowing: a half-specified `alias` is a `400` rather than a
filter that matches everyone, and a platform selector containing no recognised platform matches
**nothing** rather than silently meaning "all platforms".

### Preview the audience before you commit

`POST /v1/apps/{app_id}/audience-preview` runs the same audience resolution and the same send
planner the real send runs, so the number on your confirmation screen is the number the send will
target. It also returns counts by platform and by language, how many devices are currently capped
or held, and — for per-user timing — how many have a resolvable timezone.

## Scheduling

| Field | Meaning |
|---|---|
| `schedule_at` | Absolute time: epoch seconds, or `YYYY-MM-DDTHH:MM[:SS]`, `YYYY-MM-DD HH:MM[:SS]`, or `YYYY-MM-DD` |
| `delayed_option` | `"timezone"` (same local wall-clock time everywhere) or `"last-active"` (per-user optimal hour) |
| `delivery_time_of_day` | Only with `delayed_option: "timezone"`. `21:45`, `09:45:30` or `9:00AM`; normalised to `HH:MM` |

```json
{"title": "…", "body": "…", "schedule_at": 1788268800}
```

> **Use epoch seconds for `schedule_at`.** Date strings are parsed in the *server's* local
> timezone, so `"2026-09-01T09:00"` means different instants depending on how the container's `TZ`
> is set. Epoch seconds are unambiguous.

A scheduled message comes back with `"status": "Scheduled"` and can be cancelled with
`DELETE /v1/apps/{app_id}/messages/{message_id}` while it is still in that state. Once it starts
sending, cancelling returns `409`.

`delivery_time_of_day` without `delayed_option` is a `400`, and so is combining it with
`"last-active"`. When `delayed_option: "timezone"` is set with no time of day, `09:00` local is
used. For what `"last-active"` actually predicts, see
[Best-hour delivery](best-hour-delivery.md).

## Quiet hours and frequency capping

Both are **app settings**, not per-message flags, and both are managed through
`PATCH /v1/apps/{app_id}/settings`.

| Setting | Default | Meaning |
|---|---|---|
| `quiet_enabled` | off | Whether quiet hours apply at all |
| `quiet_start` | `08:00` | Start of the **allowed** window, in the device's local time |
| `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 |
| `freq_window_h` | `24` | The cap window, in hours |

The quiet-hours setting stores the window in which sending is **allowed**, not the window in which
it is muted. `quiet_start == quiet_end` means always allowed, and the window may wrap midnight.

Behaviour at send time:

- A device inside quiet hours is **held**, not dropped. It is released automatically when the
  window opens, and it receives the same copy it would have received at the original send time.
- A device over the frequency cap is **capped** — suppressed for that message only.
- **Only provider-accepted sends consume the cap.** A failed attempt never suppresses a person who
  received nothing. Test sends never consume it either.
- **Test devices bypass both guards on every message**, including ordinary campaigns they merely
  happen to be in the audience of.
- Per-user delivery timing composes with quiet hours rather than bypassing them: if the predicted
  hour falls in a muted period, the send is pushed to the next opening.

Both counters surface in the message report funnel as `Capped` and `Held`.

## TTL, priority and collapse key

Every one of these is optional. Absent means "we did not choose", and the provider's own default
applies — which is not the same as sending an explicit default.

| Field | Aliases | Values |
|---|---|---|
| `ttl` | `ttl_s` | Seconds. `0` is legal and means "now or never". Maximum four weeks |
| `priority` | — | `"high"` or `"normal"`. APNs' `10`/`5` and FCM's `"HIGH"`/`"NORMAL"` are accepted too |
| `collapse_key` | `collapse_id` | String, capped at Apple's 64-character `apns-collapse-id` ceiling |

```json
{"title": "Match starting", "body": "Kick-off in 2 minutes.",
 "ttl": 300, "priority": "high", "collapse_key": "match-9912"}
```

On APNs, TTL becomes an absolute `apns-expiration`; with nothing set, 24 hours is used. A
background push (no title and no body) is forced to priority 5 regardless of what you asked for,
because Apple requires it.

## Android options

Android messages are **data-only**. FCM's `notification` block is not used, which means Google
never draws the notification — your app does, through the SDK's renderer. There is consequently no
sender-side home for a channel id or an accent colour, so those ride as `op_android_*` keys inside
`data` and the app reads them:

```json
{
  "data": {
    "op_android_channel": "rewards",
    "op_android_accent": "#1FA45B",
    "op_android_large_icon": "https://cdn.acme.example/icon.png",
    "op_android_big_picture": "https://cdn.acme.example/promo.png",
    "op_android_group": "season-4"
  }
}
```

| Key | Meaning |
|---|---|
| `op_android_channel` | Notification channel id to post into |
| `op_android_accent` | Accent colour |
| `op_android_large_icon` | Large icon URL |
| `op_android_big_picture` | Expanded-image URL |
| `op_android_group` | Grouping key |

> **These are instructions to your app, not to Google.** The SDK's default
> `OpenPushNotificationRenderer` draws a plain title-and-body notification using the channel from
> your manifest meta-data; it does not itself act on `op_android_*` keys or on `image_url`. To use
> them, subclass the renderer or handle the payload yourself. The keys arrive on the device
> reliably — what they do there is your code's decision.

The FCM message OpenPush builds sets only `data`, plus `android.priority`, `android.ttl` and
`android.collapse_key`. Topic and condition targeting are not used at all; every send is
token-addressed.

## iOS options

APNs-specific settings travel as `op_apns_*` keys inside `data`. They are folded into Apple's `aps`
dictionary and stripped from the custom keys your app sees, because a subtitle or an interruption
level is rendered by the OS.

| Key | Effect | Validation |
|---|---|---|
| `op_apns_subtitle` | `alert.subtitle` | Liquid-enabled, render cap 512. Chosen per device language from `languages.<code>.subtitle`, otherwise the default subtitle |
| `op_apns_sound` | `sound` | String only. Default is `"default"` |
| `op_apns_badge` | `badge` | Integer-coerced; dropped if unparseable |
| `op_apns_category` | `category` | Set automatically when the message has action buttons |
| `op_apns_thread` | `thread-id` | Groups notifications on the lock screen |
| `op_apns_interruption` | `interruption-level` | Only `passive`, `active`, `time-sensitive` |
| `op_apns_relevance` | `relevance-score` | Clamped to 0–1 |

An invalid value is **dropped rather than sent**, because APNs answers a malformed `aps` with a 400
for the whole notification — a bad relevance score must not cost the delivery it rides on.

Two things are deliberately not supported: **critical alerts** (they need an Apple entitlement, and
`interruption-level: critical` is excluded on purpose) and `target-content-id`.

`mutable-content: 1` is set on **every** alert push, not only ones with an image. The Notification
Service Extension is the only iOS code that runs when a push arrives with the app backgrounded, so
it is the only possible source of a background Device Received receipt. It costs nothing for apps
without an extension.

> `op_android_*` and `op_apns_*` keys you place in `data` are not filtered by platform. If a send
> targets both platforms, Android keys will ride along to iOS devices and vice versa, spending
> payload budget for nothing. Scope them by sending platform-specific messages, or keep them small.

## Media and images

Two ways to attach an image:

1. **Paste an HTTPS URL.** Always available.
2. **Upload it in the console.** Media upload is a console action — there is no `/v1` media route.
   Media storage is a platform setting held by the OpenPush team and the shipped default is off;
   when it is off the console tells you to paste an HTTPS URL instead, and URL-paste keeps working.
   Paste is the path that is always available.

`image_url` validation, applied on message create, `send-test`, and template save:

- Must be `https://` with a host, or a media path hosted by OpenPush.
- Maximum 2048 characters, and no whitespace anywhere.
- A non-string value is `400 Image URLs must be text`.

Uploaded images are processed into two kinds: **image** (max 2000 px edge, min 300 px wide, about
a 1 MB budget) and **icon** (max 512 px, min 64 px wide, about 200 KB). JPEG, PNG, GIF and WEBP are
accepted as input; animated GIFs pass through untouched. There is a 40-megapixel decompression
guard, and a per-upload size ceiling of 5 MB by default.

**How the image actually renders is platform work.** On iOS the image travels as a top-level
`image_url` custom key and your Notification Service Extension fetches and attaches it — OpenPush
builds no `aps` attachment field. On Android the key arrives in the data payload and your renderer
decides what to do with it.

### Retention

Uploaded media is cleaned up by reference, not by a blind bucket lifecycle rule:

- Anything referenced by a template, an app icon, or a message that is not yet in a terminal state
  (delivered, failed, cancelled) is protected indefinitely — that covers drafts, scheduled sends
  and in-flight fan-outs.
- Once the referencing message reaches a terminal state, the reference expires after 90 days by
  default. Setting the retention window to `0` keeps media forever.
- A fresh upload always gets a 24-hour orphan grace window, so an image uploaded before its message
  exists is not swept away.
- Android `large_icon` and `big_picture` URLs are parsed out of stored composer options and
  protected too.

Media is deliberately excluded from exports: metadata without bytes would describe an archive that
cannot restore.

## Reading what happened

The create response for an immediate send already carries the first pass:

```json
{
  "id": "msg_2f7ba0c41d93",
  "title": "Season 4 is live",
  "status": "Delivered",
  "sent": 41822,
  "provider_accepted": 41590,
  "failed": 232,
  "funnel": {"Audience": 44010, "Capped": 1904, "Held": 284, "Remaining": 41822,
             "Sent": 41822, "Failed": 232, "Retryable": 12, "Delivered": 41590},
  "note": "provider_accepted = FCM took it; device receipts arrive via /v1/ingest",
  "report": "/v1/apps/acme-app/messages/msg_2f7ba0c41d93"
}
```

Fetch the full report at any time:

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

### The receipt ladder

| Stage | Source | What it means |
|---|---|---|
| **Provider Accepted** | The send itself | Apple or Google took the message |
| **Device Received** | `/v1/ingest`, from the SDK | The data actually reached the device |
| **Confirmed Receipt** | `/v1/ingest` | The notification was displayed |
| **Clicked** | `/v1/ingest` | Someone tapped it |

The last three only appear if your app posts them back, which the Android and iOS SDKs do once
integrated. Timestamps are written first-observation-wins, so a replayed receipt cannot move a
timestamp or double-count.

### Funnel keys

`Audience`, `Capped`, `Held`, `Remaining`, `Sent`, `Failed`, `Retryable` and `Delivered` are always
present, and `Sent = Failed + Delivered` always holds. `Queued`, `Retrying` and `Dead` appear
**only when they are non-zero** — a column of permanent noughts trains an operator to stop reading
the row. Write your parser to tolerate their absence.

The report also carries `render_errors` (a count of fields that failed to render, were truncated at
their cap, or blew the 4 KB payload budget), and for scheduled or per-user sends, `delivering`,
`spread_done`, `spread_total` and `spread_ends`.

### Listing messages

`GET /v1/apps/{app_id}/messages` returns recent sends with their headline counters. Pass
`?scheduled=1` to see only messages waiting to go out; the default (`0`) returns everything except
scheduled and draft messages. Test sends are always excluded.

## Test sends

`POST /v1/apps/{app_id}/send-test` takes the same content body as a real send, targets **only**
registered test subscriptions, and never creates a Sent Messages row.

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/send-test \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Copy check", "body": "Does this fit on a lock screen?"}'
```

Register a test device with `POST /v1/apps/{app_id}/test-subscriptions`, passing either a
`subscription_id` or a raw `token`, plus an optional `name`. Registering one immediately releases
anything currently quiet-hours-held for that device.

Remember the trade-off: a test subscription ignores the frequency cap and quiet hours on
**every** send, not just test sends. That is exactly what you want on a development handset and
exactly what you do not want on a real customer's phone.

## Limits

| Limit | Value |
|---|---|
| Rendered payload per device | 4 KB. Over-size payloads are truncated deterministically (body first, then title) and flagged with `op_render_truncated`; still over, the delivery is refused |
| Title render cap | 512 characters |
| Body render cap | 2048 characters |
| `image_url` / `deep_link` render cap | 2048 characters |
| Action buttons | 1–3 in `actions`; labels ≤256 characters on input and ≤48 after rendering |
| `custom_data` | 2 KB serialized |
| A/B variants | 2 to 10 arms |
| TTL | 0 seconds to 4 weeks |
| Collapse key | 64 characters |
| Request body | 8 MB by default |

Also true, and worth planning around:

- **Retry sends with an `Idempotency-Key` header.** A 16–128 character key replays the
  same request result for 24 hours. Reusing it with a different body returns `409`.
- **No rate limit on the send route** — nothing throttles you but your own provider quotas.
- **No global send-rate throttle, drip, or spread-over-N-hours control.**
- **No per-message quiet-hours or frequency-cap override.** Both are app-level.
- **No message-level goals, conversions, or attribution.** The receipt ladder is the whole
  measurement surface.
- A payload whose title *and* body both render empty is refused rather than sent.

## FAQ

**Can I send the same message to iOS and Android with different copy?**
Not in one call, beyond per-language variants. Send two messages with `target.platform` set, or use
A/B variants if what you want is a comparison rather than a per-platform difference.

**Why is `Audience` bigger than `Sent`?**
`Audience` counts everyone the rules matched. `Capped` and `Held` are subtracted from it, and
Best-hour delivery parks devices for later. `Remaining` is what actually went into the queue on
this pass.

**How do I stop a scheduled campaign?**
`DELETE /v1/apps/{app_id}/messages/{message_id}` while it is still `Scheduled`. After that it is
`409` — the fan-out has begun.

**Does a held device get stale copy when quiet hours open?**
It gets exactly the copy it would have received at the original send time. There is one definition
of a device's payload, used by the first attempt, by every retry, and by the quiet-hours release,
specifically so a device released at 08:00 cannot receive different copy from one sent at 22:00.

**What happens to a device whose token has died?**
Apple's `410 Unregistered` and FCM's `NOT_FOUND`/`UNREGISTERED` mark the subscription uninstalled;
`BadDeviceToken` marks it unsubscribed. Provider configuration faults (a bad p8, an expired token,
a refused service account) stop the fan-out and touch **no** subscriptions — a credential mistake
can never mass-unsubscribe your audience.

## Related

- [Segments](segments.md) — building the audience
- [Personalization](personalization.md) — Liquid, tags and dynamic content
- [Templates](templates.md) — reusable content
- [A/B testing](ab-testing.md) — variants and promotion
- [Best-hour delivery](best-hour-delivery.md) — per-user send timing
- [Messages API reference](../api-handbook/02-messages.md)
