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. For the exact request and response shapes, see the messages API reference.
Prerequisites
- An app with at least one platform credential configured (APNs, FCM).
- 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.
Code
| 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.
varsdoes 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 ascustom_datainstead.
Action buttons
Add one to three mobile notification buttons with the top-level actions field:
Code
| 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. In the composer,
each language tab has a Button labels field, which Translate with AI fills. For
display integration, see the
iOS and
Android 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:
Code
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
Code
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
for the filter language and worked recipes.
Direct targeting
For transactional sends, target addresses one person or device without a segment:
Code
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 |
Code
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'sTZis 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.
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 |
Code
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:
Code
| 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
OpenPushNotificationRendererdraws a plain title-and-body notification using the channel from your manifest meta-data; it does not itself act onop_android_*keys or onimage_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_*andop_apns_*keys you place indataare 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:
- Paste an HTTPS URL. Always available.
- Upload it in the console. Media upload is a console action — there is no
/v1media 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
0keeps 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_iconandbig_pictureURLs 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:
Code
Fetch the full report at any time:
Code
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.
Code
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-Keyheader. A 16–128 character key replays the same request result for 24 hours. Reusing it with a different body returns409. - 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 — building the audience
- Personalization — Liquid, tags and dynamic content
- Templates — reusable content
- A/B testing — variants and promotion
- Best-hour delivery — per-user send timing
- Messages API reference