Messages
A message is one send: its content, its audience, its timing, and the report that accumulates afterwards. Creating a message is the primary write operation in OpenPush — everything else on this page reads, cancels, previews, or tests one.
Examples use https://app.openpush.ai, the OpenPush API base URL.
The metric ladder
Message reports keep four delivery stages deliberately distinct, and they mean different things:
| Stage | Meaning |
|---|---|
| Provider Accepted | APNs or FCM took the notification. This is the furthest OpenPush can see on its own |
| Device Received | The SDK on the device got the data |
| Confirmed Receipt | The notification was actually displayed |
| Clicked | Someone tapped it |
Nothing in OpenPush calls Provider Accepted "delivered". The last three stages arrive asynchronously from devices via the ingest route — see Events and ingest.
POST /v1/apps/{app_id}/messages
Creates and sends a message, or schedules one.
Auth: X-OP-API-Key (this app's REST key).
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
app_id | string | yes | The app slug |
Send an
Idempotency-Keyheader when a caller may retry. The same key and body replay the original response for 24 hours without another send. A changed body with the same key returns409.
Unknown top-level message fields and unsupported audience or platform fields return 400.
Check accepted fields against the API reference before sending.
Content
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | yes* | Notification title. Liquid-enabled. Rendered output capped at 512 characters |
body | string | yes* | Notification body. Liquid-enabled. Rendered output capped at 2048 characters |
image_url | string | no | HTTPS image URL, or a media path hosted by this server. Liquid-enabled, capped at 2048 characters. Validation rules: API overview |
deep_link | string | no | Delivered to the device as deep_link in the data payload for your app to route on. Liquid-enabled, capped at 2048 characters |
name | string | no | Campaign name, used in reports and the message list. Defaults to title |
created_by | string | no | Free-text attribution shown on the report. Defaults to "api" |
subtitle | string | no | iOS subtitle. An explicit platform_options.ios.subtitle takes precedence |
* Required unless a referenced template supplies both. If neither the body nor the
resolved template yields a title and a body, the request is
400 "need title+body or a known template".
vars is accepted but does nothing. It is parsed and discarded rather than applied as
a render pass, because rendering message-wide variables at compose time would collapse
each device's tags, language and dynamic content before the send pipeline ever saw them.
Use Liquid with per-device context instead, or custom_data for message-wide values. Do
not build on vars.
Notification actions
actions is an optional array of one to three mobile notification buttons. Each button
needs a unique id (1–64 letters, digits, ., _, or -) and a non-blank label;
icon is optional. A malformed list or unknown button field returns 400 before the
message is created. Use the typed field instead of the internal data.op_actions key.
Code
Labels can be translated per language with languages.<code>.action_labels (see
Per-language content); the button id stays the same in every
language. The mobile click callback receives the selected action ID. Message reports currently
count button taps as clicks without attributing them to individual buttons.
The iOS SDK guide shows the
notification service extension call needed to display the buttons. The
Android SDK guide covers the bundled renderer and click handling.
Template reference
| Parameter | Type | Required | Description |
|---|---|---|---|
template | string | no | Template id or name |
template_id | string | no | Accepted as an alias for template |
The reference is resolved against your app's saved templates first, matching on either the
id or the name. If nothing matches, it falls back to the built-in templates that ship with
the server: welcome_flock, comeback_1, first_push_test. An unresolvable reference is
404 "unknown template <ref>".
Fields you supply in the body win over the template's. A saved template contributes
title, body, image_url, deep_link, data, languages, default_language, and
platform_options where you left them out. A created message stores a content snapshot,
so template edits do not change scheduled sends. Note the asymmetry:
image_url, deep_link and data are inherited only from a saved template, not from
a built-in one — built-ins contribute title and body.
Sending with a saved template increments that template's send counter. Built-in templates
are listed under builtin on
GET /v1/apps/{app_id}/templates.
Per-language content
| Parameter | Type | Required | Description |
|---|---|---|---|
languages | object | no | {code: {title, body, subtitle, action_labels}} — per-language copy |
default_language | string | no | Lowercased and trimmed on the way in |
Language codes are normalised: lowercased, underscores turned into hyphens, and entries with nothing written in them are dropped.
Each entry takes four optional fields:
| Field | Description |
|---|---|
title | Title in this language |
body | Body in this language |
subtitle | iOS subtitle in this language. Without one, the device gets the message's default subtitle |
action_labels | {action_id: label}. Button labels in this language, keyed by the id of a button in actions. A button without an entry keeps its default label. Labels are trimmed and capped at 48 characters, and keys that are not valid action ids are dropped |
Selection per device is exact match first, then base language. A device reporting
pt-BR receives the pt-br variant if one exists, otherwise the pt variant, otherwise
the top-level title/body. A variant that fills only one of the two fields inherits the
other, so a half-translated entry can never ship an empty body.
The subtitle and button labels follow the same selection as the title. A translated subtitle is sent even when the message has no default subtitle.
Language selection happens before Liquid rendering, so {{ }} expressions resolve
inside translated copy rather than only inside the default copy.
Code
Here a pt device gets the Portuguese title and body with the default subtitle and button labels.
Targeting
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
include_segments | array of strings | no | [] | Segment ids whose members are included |
exclude_segments | array of strings | no | [] | Segment ids whose members are removed from the result |
target | object | no | {} | Direct targeting — see below |
filters | array of objects | no | — | Inline audience predicates; AND by default, with OR-group separators |
target sub-fields:
| Field | Type | Description |
|---|---|---|
external_id | string | Exact match on the user's external id |
external_ids | array | 1–100 external ids, ORed and deduplicated |
token | string | Exact match on a device push token |
subscription_id | string | Exact match on a subscription id |
subscription_ids | array | 1–100 subscription ids, ORed and deduplicated |
alias | object | {"label": "…", "id": "…"} — both keys are required |
aliases | object | {"label":"account","ids":["42","43"]} — 1–100 values |
platform | string | One of ios, android, web |
platforms | array of strings | The plural form; same accepted values |
Two safety behaviours worth knowing:
- A half-specified
alias(a label with no id, or an id with no label) is a400, never a match-everything. An alias label containing noA-Z a-z 0-9 _ . -characters is also a400. - An explicit platform selector that contains no valid platform matches nothing — it never quietly widens to "all platforms".
Use one identity selector kind per request. filters, saved segments, and platform
selectors narrow its result. Only sendable subscriptions are targeted: status above zero
and not retired.
filters is a flat list of up to 20 saved-segment predicates. Each rule names a field and
op, with a value when required. Tag rules also require a key. Predicates are combined
with AND by default; insert {"operator":"or"} between groups to combine the groups with OR.
Predicates within each group remain ANDed:
Code
Supported fields and operators:
| Field | Operators | Extra values |
|---|---|---|
tag | is, is_not, exists, not_exists, greater, less, in, not_in | key is required; in/not_in accept 1–50 values |
country, language, app_version | is, is_not, in, not_in | in/not_in accept 1–50 values |
device_type | is, is_not | ios, android, or web |
first_session, last_session | greater, less | value is hours ago |
session_count | greater, less, is | value is a session count |
total_session_duration | greater, less, is | value is minutes |
test_users | is | value is boolean-like |
location | within | value is radius in meters; supply lat and lng |
Filters intersect with the identity selector and included/excluded segments. Missing tags
match not_in and not_exists, but not in or exists.
Code
Scheduling and per-user delivery timing
| Parameter | Type | Required | Description |
|---|---|---|---|
schedule_at | number or string | no | Absolute send time. Epoch seconds, or one of YYYY-MM-DDTHH:MM:SS, YYYY-MM-DDTHH:MM, YYYY-MM-DD HH:MM:SS, YYYY-MM-DD HH:MM, YYYY-MM-DD |
delayed_option | string | no | "timezone" or "last-active" |
delivery_time_of_day | string | no | Local wall-clock time, e.g. 21:45, 09:45:30, 9:00AM. Only valid with delayed_option: "timezone" |
There is no send_after field. Absolute scheduling is schedule_at.
Timezone caveat. A
schedule_atgiven as a date string is parsed in the server's local timezone, so"2026-09-01T09:00"means different instants under different containerTZsettings. Pass epoch seconds whenever the exact moment matters.
The two delayed_option modes:
"timezone" — every user receives the message at the same local wall-clock time.
delivery_time_of_day sets that time; when the field is absent the server uses 09:00.
The release is the next occurrence of that local time strictly after now, in each device's
zone.
"last-active" — Best-hour delivery. Each user gets their own predicted best local
hour, blended from their own activity history and an app-wide prior, plus a deterministic
per-device jitter of up to one hour so a whole cohort does not fire on the same second.
A user with no evidence anywhere in the app is sent to immediately rather than parked.
Behaviour in depth: Best-hour delivery.
Combination rules, enforced strictly so a typo surfaces now rather than in a report later:
| Combination | Result |
|---|---|
delivery_time_of_day with no delayed_option | 400 "delivery_time_of_day needs delayed_option 'timezone'" |
delivery_time_of_day with delayed_option: "last-active" | 400 — last-active picks each user's hour itself |
delayed_option other than timezone or last-active | 400 "delayed_option must be 'timezone' or 'last-active'" |
An unparseable delivery_time_of_day | 400 "delivery_time_of_day must look like 21:45, 09:45:30 or 9:00AM" |
schedule_at and delayed_option compose: schedule_at decides when the campaign fires
on the server, and delayed_option decides when each device is released after that.
These fields are retained for existing integrations.
Delivery options
Every field here is optional, and absent means "we did not choose" — the provider's own default applies rather than an OpenPush default.
For presentation controls, use validated platform_options: {"ios": {...}, "android": {...}}. For per-message pacing and fatigue controls, use
delivery_policy: {"frequency_cap": 2, "throttle_per_minute": 100}. A
delivery policy can only tighten the app's configured limits and message
throttling cannot be combined with best-hour delivery. The
backend API guide gives complete examples.
| Parameter | Aliases | Type | Description |
|---|---|---|---|
ttl | ttl_s | int (seconds) | How long the provider may keep trying. 0 is legal and means "now or never" |
priority | — | string or int | "high" or "normal". APNs' 10/5 and FCM's "HIGH"/"NORMAL" are accepted spellings |
collapse_key | collapse_id | string | Notifications sharing a collapse key replace one another on the device |
| Validation | Result |
|---|---|
ttl not a number | 400 "ttl must be a number of seconds, not …" |
ttl negative | 400 "ttl cannot be negative" |
ttl above four weeks | 400 naming the ceiling — FCM refuses more |
priority anything else | 400 "priority must be 'high' or 'normal', not …" |
collapse_key too long | 400 naming your length and the apns-collapse-id ceiling |
The chosen values are echoed on the create response and stored on the report, because a Provider Accepted with a three-day TTL and one with a four-week TTL are not the same promise.
A/B variants
| Parameter | Type | Required | Description |
|---|---|---|---|
variants | array of objects | no | 2 to 10 arms. Fewer or more is 400 "variants must contain between 2 and 10 arms" |
ab | object | no | Experiment configuration. Ignored when variants is absent |
Each entry in variants:
| Field | Type | Description |
|---|---|---|
name | string | Human label, truncated to 80 characters. Defaults to "Variant A", "Variant B", … |
title | string | Arm title. Falls back to the message-level title |
body | string | Arm body. Falls back to the message-level body |
languages | object | Per-language copy for this arm, same shape as the message-level languages. The first arm inherits the message-level languages when it supplies none; later arms do not |
image_url | string | Accepted and stored, but see the note below |
deep_link | string | Accepted and stored, but see the note below |
Arm ids are assigned by the server in order: A, B, C, … Anything you put in an arm's
id is replaced. Every arm object must be an object, or the request is
400 "every variant must be an object".
Per-arm
image_urlanddeep_linkare not applied at send time. The rendered payload takes both from the message-level fields regardless of which arm a device is in. Vary copy across arms; do not expect to vary the image or the link.
ab fields:
| Field | Type | Default | Description |
|---|---|---|---|
test_pct | int | 25 | Percentage of the audience that receives a test arm. Must be 1–100; anything else is 400. A non-integer is 400 "ab.test_pct must be a whole percentage" |
winner | string | null | Normally set by promotion rather than by you |
promoted_at | number | null | Set by promotion |
auto | object | — | {"enabled": false, "after_h": 24, "min_per_arm": 100} |
auto.enabled | bool | false | Whether to auto-promote |
auto.after_h | number | 24 | Hours to observe before picking a winner. Clamped to a minimum of 0 |
auto.min_per_arm | int | 100 | Every test arm must have at least this many sends before a winner is picked. Clamped to a minimum of 1 |
Assignment is deterministic. Each device's arm is derived from a hash of
message id : subscription id, so it is stable across retries, across the quiet-hours
release path, and across a redrive after a process restart. Devices whose hash falls above
test_pct are the holdback wave: they are assigned no arm and receive nothing until a
winner is promoted. The holdback and the test wave are device-disjoint by construction.
With test_pct: 100 there is no holdback, and promotion is refused later with a 409.
Note what the funnel does here: Audience counts the whole resolved audience including
the holdback, while Sent counts only the test wave. That gap is the holdback, not a
failure.
Reading results, promoting a winner and auto-promotion: Promote a winner and A/B testing.
Data payload
| Parameter | Type | Required | Description |
|---|---|---|---|
data | object | no | Custom key/value data delivered to the device alongside the notification |
data must be a JSON object — 400 "data must be a JSON object" otherwise — and it is
validated at compose time to be provider-safe. FCM's data map carries strings only, so
values are checked and normalised: strings stay byte-identical, true/false/null and
numbers are spelled as JSON, and nested objects and arrays are serialised deterministically
to JSON strings. NaN and Infinity are refused, because they are not JSON. A failure is
400 "data is not provider-safe JSON: …".
Reserved keys. title, body, op_message_id and op_app are written after your
data and always win over it. So are the three delivery-option keys — op_ttl,
op_priority and op_collapse_id — whenever the corresponding body field was set, so a
data blob carrying op_ttl cannot overrule the TTL you chose on the message.
Values in data stay literal. Liquid is not rendered inside custom data. A value
containing "{{ first_name }}" is delivered exactly as written. The separate
top-level actions[].label field is rendered for each recipient.
Button payload internals
The public create request uses top-level actions. The server
validates them before creating a message, then serializes the resulting buttons into
op_actions for the mobile SDKs. Do not send data.op_actions in a new request.
The iOS notification service extension helper registers the matching category; the
Android FCM module renders the buttons. Both pass the selected ID to the app's click
callback. Reports count the click without identifying which button was tapped.
Platform presentation
Use the typed platform_options body field for device presentation. The server validates
these values and applies each platform's options only to its recipients:
Code
The iOS object accepts subtitle, sound, badge, interruption, relevance,
thread, category, target_content, and content_available. The Android object
accepts channel, accent, large_icon, big_picture, group, category,
visibility, sound, small_icon, and led. Unknown options return 400. The
bundled Android FCM renderer uses these values, including image_url; apps with a
custom renderer can read them from the delivered payload. A channel already created
by the host app is preserved; an unknown channel ID is created with default
importance. See the Android SDK guide.
Message-wide variables
| Parameter | Type | Required | Description |
|---|---|---|---|
custom_data | object | no | Values exposed to Liquid as message.custom_data. Hard cap of 2 KB serialised — over that is 400 "custom_data exceeds 2 KB" |
Unlike data, custom_data is not delivered to the device. It exists so a campaign can
carry values its copy references — a promo code, an event name, a deadline — without
inventing a tag for each one.
Where Liquid is applied
Liquid is compiled and validated at compose time across every content source: title,
body, image_url, deep_link, every languages.<code>.title, .body, .subtitle
and .action_labels.<id>, every variants.<arm>.title and .body and their per-language
forms, every action label, and the iOS subtitle.
A syntax error anywhere in that set is 400 "<field>: <error> (line N, column C)" — the
field name tells you which one.
Rendering is per device, at send time, against that device's tags, language, country,
external id, timezone, platform and app version, plus message.custom_data and any
dynamic-content tables the copy references. Render caps are 512 characters for title,
2048 for body, image_url and deep_link, and 48 for an action label.
Missing variables render empty rather than failing, and a render error degrades that one
field to its literal source rather than failing the send — the count of such events is
kept on the report as render_errors.
Dynamic-content tables are validated by name at compose time: referencing a table that does not exist fails the create call. Their values are snapshotted when delivery begins, so a scheduled send freezes the table contents at fire time, not at compose time.
Syntax, the render namespace, fallbacks and preview: Personalization.
Payload size
The rendered per-device payload is capped at 4 KB, FCM's data envelope.
Over-size payloads are truncated deterministically — body first, then title — and marked
with op_render_truncated: "1". Truncation is deterministic on purpose: a provider-side
cut would bias an A/B experiment. If the payload is still over 4 KB after truncation, that
delivery is refused. A payload whose title and body both render empty is also refused.
Every one of these events increments the message's render_errors counter.
Examples
Simplest possible immediate send to a segment:
Code
A personalized, localized, scheduled send with delivery options:
Code
A two-arm A/B test with best-hour delivery and auto-promotion:
Code
Response
Immediate send — the call blocks through the fan-out and returns the outcome.
Code
Scheduled send — nothing is delivered yet.
Code
Both responses are 200. report is the relative path of the full report — poll it for
receipt counts, which arrive after the response.
Errors
| Status | Body | Cause |
|---|---|---|
400 | need title+body or a known template | Neither the body nor the template supplied both fields |
400 | data must be a JSON object | data was a string, array or number |
400 | data is not provider-safe JSON: … | A value in data cannot be carried by FCM's data map |
400 | custom_data exceeds 2 KB | Serialised custom_data is over the cap |
400 | variants must contain between 2 and 10 arms | 0, 1 or more than 10 arms |
400 | every variant must be an object | A non-object entry in variants |
400 | ab.test_pct must be a whole percentage / ab.test_pct must be between 1 and 100 | Invalid split |
400 | bad schedule_at … | Not epoch seconds and not one of the accepted date formats |
400 | delayed_option must be 'timezone' or 'last-active' | Unrecognised mode |
400 | delivery_time_of_day needs delayed_option 'timezone' | Time of day supplied without a mode |
400 | delivery_time_of_day must look like 21:45, 09:45:30 or 9:00AM | Unparseable time |
400 | ttl cannot be negative / ttl must be a number of seconds … / a ceiling message | Invalid TTL |
400 | priority must be 'high' or 'normal', not … | Invalid priority |
400 | a collapse-key length message | Over the apns-collapse-id ceiling |
400 | An image-URL message | See image URL rules |
400 | <field>: <liquid error> | A Liquid syntax error in a content field |
400 | A segment or target message | Malformed segment filter, or a half-specified target.alias |
400 | unknown dynamic-content table(s): … | Copy referenced a dynamic-content table that does not exist. Missing tables fail loudly at compose time rather than rendering empty |
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
403 | the sample app sends from the console only — it has no REST send path | The shared sample app has no REST send path |
404 | unknown app '<id>' | No such app |
404 | unknown template '<ref>' | The template reference matched neither a saved nor a built-in template |
404 | no matching subscriptions — did the app register? | An immediate send whose targeting resolved to zero sendable devices |
413 | Dynamic content quota message | A referenced dynamic-content table exceeded a size quota |
Two asymmetries worth planning around:
- The empty-audience
404applies to immediate sends only. A scheduled send with an empty audience today is created successfully, because the audience is resolved when it fires. 403on the shared sample app is checked before anything else touches the app, so it is the answer you get even if the rest of the body is invalid.
Retries and duplicate sends
Use Idempotency-Key for a message create that may be retried. The same key and
body replay the original response for 24 hours. A different body with that key
returns 409.
When a request omitted the header:
- Give every campaign a distinct
name. - On a timeout, call
GET /v1/apps/{app_id}/messagesand look for that name before retrying. - For large sends, prefer
schedule_ata minute or two out: a scheduled create is cheap to verify withGETand cheap to undo withDELETE.
GET /v1/apps/{app_id}/messages
Lists messages with their headline counters.
Auth: X-OP-API-Key.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
scheduled | int | no | 0 | 1 returns only messages in Scheduled. 0 returns everything except Scheduled and Draft |
limit | int | no | 50 | 1–200 rows per page |
cursor | string | no | — | Opaque next_cursor from the previous page |
status | string | no | — | Exact message status filter |
name | string | no | — | Exact campaign name filter |
Test sends are always excluded from both branches. Console drafts are excluded from both as well — a draft is neither sent nor scheduled.
Code
Code
Rows are ordered by immutable creation time and ID, newest first. The cursor is
bound to the app, queue, and filters. Pass next_cursor back as cursor for the
next page. A message that leaves the scheduled queue while paging is absent
from later pages.
Errors
| Status | Body | Cause |
|---|---|---|
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
GET /v1/apps/{app_id}/messages/{message_id}
The full report for one message.
Auth: X-OP-API-Key.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
app_id | string | yes | The app slug |
message_id | string | yes | The message id returned by create |
Code
Definition and content
| Field | Type | Description |
|---|---|---|
id, app, name, title, body | string | As created |
status | string | Draft, Scheduled, Sending, Delivered, Failed or Canceled |
created_by | string | Attribution recorded at create time |
delivery | string | immediate or scheduled |
schedule_at, sent_at, created_at | number | null | Epoch seconds |
delayed_option, delivery_time_of_day | string | null | Per-user timing as chosen |
is_test | bool | Whether this was a send-test |
imported | bool | Whether the counters come from an imported history rather than per-device rows |
template_id | string | null | The saved template used, if any |
image_url, deep_link | string | null | As created |
data | object | The custom data payload as created |
ttl_s, priority, collapse_key | — | As chosen. null means "not set", so the provider default applied |
languages, default_language | — | As created |
include_segments, exclude_segments | array | Segment ids as created |
error | string | null | Set only when the send stopped on a configuration fault, such as a rejected APNs provider token. Never set for a per-device failure |
render_errors | int | Count of render problems: truncations, refusals and per-field degradations |
audience_est_at | number | null | Non-null only on a message that has not fired — the moment its audience estimate was taken |
Counters
| Field | Type | Description |
|---|---|---|
Provider Accepted | int | APNs/FCM took it |
Device Received | int | Devices that reported receiving the data |
Confirmed Receipt | int | Devices that reported displaying it |
Clicked | int | Distinct devices that tapped it |
CTR | string | Preformatted, "—" when nothing was sent |
ctr_value | number | The same figure as a float |
funnel
| Key | Description |
|---|---|
Audience | Everyone the targeting resolved to, including any A/B holdback |
Capped | Suppressed by the frequency cap for this message |
Held | Waiting on quiet hours — held, not dropped |
Remaining | Audience − Capped − Held, floored at zero |
Sent | Devices actually attempted |
Failed | Devices that did not get through |
Retryable | How many of those failures were the network's fault rather than the device's |
Delivered | Equal to Provider Accepted |
Queued | Present only when non-zero — attempts not yet made |
Retrying | Present only when non-zero — attempts in backoff |
Dead | Present only when non-zero — attempts given up on |
The invariant Sent = Failed + Delivered always holds. The three conditional keys are
absent rather than zero, so read them with a default.
Per-user delivery progress
| Field | Type | Description |
|---|---|---|
delivering | bool | Whether a per-user timed send is still releasing |
spread_done | int | Devices released so far |
spread_total | int | Devices released plus devices still parked |
spread_ends | number | null | When the last parked device is due |
A/B fields
| Field | Type | Description |
|---|---|---|
variants | array | The normalised arms, with server-assigned ids |
ab | object | The experiment configuration, including winner and promoted_at once promoted |
testing | bool | true while arms exist and no winner has been promoted |
winner | string | null | The promoted arm id |
significance | number | null | Confidence percentage. Computed only for exactly two test arms, each with at least one send, as a two-proportion z-test |
arm_stats | array | Per arm and wave — see below |
Each arm_stats entry: id, name, wave, sent, accepted, clicked, failed,
ctr_value. wave is "test" for the experiment itself and "winner" for devices
reached by promoting a winner to the holdback.
Errors
| Status | Body | Cause |
|---|---|---|
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
404 | unknown message | No such message in this app |
DELETE /v1/apps/{app_id}/messages/{message_id}
Cancels a scheduled message. This is a cancel, not a delete — the row remains and its
status becomes Canceled.
Auth: X-OP-API-Key.
Code
Code
Only a message currently in Scheduled can be cancelled. Once the scheduler has claimed
it, cancellation is no longer possible — there is no route that recalls a send in flight,
and no route that recalls a notification already on a device.
Errors
| Status | Body | Cause |
|---|---|---|
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
409 | message is not Scheduled (already sending, sent or canceled) | Wrong state, or the message id does not exist in this app |
POST /v1/apps/{app_id}/messages/{message_id}/promote
Sends a winning arm's copy to the A/B holdback wave.
Auth: X-OP-API-Key.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
winner | string | yes | An existing arm id: A, B, … Case-insensitive |
Code
The response is the full message report, in the same shape as
GET /v1/apps/{app_id}/messages/{message_id}, reflecting the promotion run.
Devices already reached in the test wave are excluded from the promotion — receipts dedupe
by message id, and on Android a second push with the same id would replace the tray
notification. Only the holdback receives the winner, and those deliveries are recorded with
wave: "winner".
Auto-promotion. With ab.auto.enabled, a scheduler pass promotes for you once
ab.auto.after_h hours have passed since the message was created and every test arm
has at least ab.auto.min_per_arm sends. The arm with the highest CTR wins, ties broken by
arm id. Until both conditions hold, nothing happens.
Errors
| Status | Body | Cause |
|---|---|---|
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
404 | unknown message | No such message in this app |
409 | winner must name an existing A/B arm | The message has no variants, or the arm id is unknown |
409 | a winner has already been promoted | Promotion is one-shot |
409 | this experiment has no holdback to promote | test_pct was 100 |
POST /v1/apps/{app_id}/send-test
Sends to the app's registered test subscriptions only. Never creates a Sent Messages row.
Auth: X-OP-API-Key.
Body: the same content fields as message create — title, body,
template/template_id, image_url, deep_link, data — plus:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | no | "Send Test" | Campaign name recorded on the test message |
Everything else is ignored on this route. There is no targeting (the audience is the test
list), no scheduling, no variants/ab, no languages, and no ttl/priority/
collapse_key. created_by is recorded by the server, not taken from the body.
Code
Code
Test devices bypass the frequency cap and quiet hours on every send — not just on send-test, but on ordinary campaigns they merely happen to be in the audience of. That is what makes a test device useful and what makes it unrepresentative: never read a test device's behaviour as evidence about the guards.
Manage the test list with the test-subscription routes in Subscriptions and users.
Errors
| Status | Body | Cause |
|---|---|---|
400 | Any content-field error from the create route | Same validation |
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
403 | the sample app sends from the console only — it has no REST send path | Shared sample app |
404 | unknown app '<id>' | No such app |
404 | no test subscriptions — add one first | The test list is empty, or every entry is unsendable |
POST /v1/apps/{app_id}/audience-preview
Answers "what would this send do right now" without sending. It runs the same audience resolution and the same planner the real send runs, so the number on your confirm screen is the number the send will target.
Auth: X-OP-API-Key.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
include_segments | array | no | As on create |
exclude_segments | array | no | As on create |
target | object | no | As on create |
delayed_option | string | no | "timezone" or "last-active". Changes how the plan classifies devices |
delivery_time_of_day | string | no | Validated with the same rules as on create |
title | string | no | Rendered against a sample device for a copy preview |
body | string | no | Rendered against a sample device for a copy preview |
Code
Code
| Field | Type | Description |
|---|---|---|
at | number | The instant the plan was computed |
audience | int | Devices the targeting resolved to |
capped | int | Devices the frequency cap would suppress |
held | int | Devices quiet hours would hold |
timed | int | Devices that would be parked for per-user delivery |
tz_known | int | How many of those have a timezone that actually resolves. The rest fall back to UTC |
avg_pct | int | null | For last-active: the average share of each prediction that came from the user's own history rather than the app-wide prior. Not a headcount |
no_data | int | For last-active: users with nothing to predict from, who would be sent to immediately |
fallback_hour | int | null | The app-wide peak local hour used when a user has no history |
remaining | int | Devices that would be sent to immediately |
platforms | object | Counts by platform across the whole audience |
languages | object | Counts by reported language code across the immediate targets. "" means the device reported none |
settings | object | The app settings the plan was computed against |
preview_title, preview_body | string | The title and body rendered against the first device in the audience |
render_errors | array of strings | Liquid problems hit while rendering the preview copy. Note the shape: an array here, unlike the integer render_errors on a message report |
This is a point-in-time answer. A device can install, uninstall or cross a timezone boundary between this call and the send.
Dynamic content is not resolved on this route. Its render context is built without dynamic-content tables, so
{{ dynamic_content.* }}renders empty here. Use render-preview to preview copy that uses dynamic content.
Errors
| Status | Body | Cause |
|---|---|---|
400 | A segment or target message | Malformed filter or target |
400 | A delayed_option / delivery_time_of_day message | Same validation as create |
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
POST /v1/apps/{app_id}/render-preview
Renders every personalization target against a sample device you supply. This is the route to use when you are debugging Liquid, and the only preview route that resolves dynamic content.
Auth: X-OP-API-Key.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
subscription | object | no | A sample device: any of id, platform, language, app_version, external_id, tags, country, timezone |
tags | object | no | Overrides subscription.tags |
external_id | string | no | Overrides subscription.external_id |
language | string | no | Overrides subscription.language |
country | string | no | Overrides subscription.country |
message_id | string | no | Exposed as message.id. Defaults to "preview" |
name | string | no | Exposed as message.name. Defaults to "Preview" |
custom_data | object | no | Exposed as message.custom_data |
title | string | no | Source to render |
body | string | no | Source to render |
image_url | string | no | Source to render |
deep_link | string | no | Source to render |
subtitle | string | no | Source to render — the iOS subtitle |
Code
Code
| Field | Type | Description |
|---|---|---|
rendered | object | The five sources after rendering. Caps: 512 characters for title and subtitle, 2048 for body, image_url and deep_link |
errors | array | {"field": "…", "message": "…"} per problem — syntax errors and cap truncations |
dynamic_content | array of strings | The dynamic-content tables the sources referenced |
A source with a syntax error is returned as its literal text with the error listed in
errors, matching what the send pipeline does.
Errors
| Status | Body | Cause |
|---|---|---|
400/413 | unknown dynamic-content table(s): … and quota messages | A referenced table does not exist, or a size quota was exceeded. Missing tables fail loudly here rather than rendering empty |
401 | bad X-OP-API-Key | Missing, wrong, disabled, or wrong-app key |
Delivery guards
Two per-app settings sit between a resolved audience and a send. Both are configured on app settings, not per message — there is no per-message quiet flag and no per-message cap class.
Quiet hours (quiet_enabled, off by default) store the interval during which sending
is allowed, in each device's local time. quiet_start == quiet_end means always
allowed, and the window may wrap past midnight. A device inside quiet hours is held —
it counts as Held on the funnel and is released when its window opens, through the same
queue and the same worker as a first attempt. A device released at 08:00 receives byte-for-
byte the copy a device sent at 22:00 received.
Frequency capping (freq_cap, default 10 per freq_window_h = 24 hours, 0 disables)
suppresses a device that has already received its quota. It counts as Capped. Two rules
make the count honest: only provider-accepted sends consume the cap, so a failed
attempt never suppresses someone who received nothing; and test messages never consume it.
Interaction with per-user timing. A chosen local hour is composed with the allowed window, never allowed to bypass it — if the predicted hour falls outside quiet hours, the release is pushed to the next opening. The frequency cap still wins over everything.
Test devices bypass both guards on every message, including ordinary campaigns they merely appear in.
Sendability
A subscription is targetable when its status is above zero and it has not been retired. Every positive status counts as subscribed; on iOS the positive value doubles as the notification authorization bitmask. Retirement is OpenPush's own lifecycle state for a superseded or unlinked address, and it excludes a row from sends without destroying the provider status that row records. Status values in full: Subscriptions and users.
Limits
- No idempotency. Retrying a create is a second send.
- No rate limit on message create or send-test. Pace bulk work yourself.
- Rendered payloads are capped at 4 KB; over-size payloads are truncated and, if still over, refused.
custom_datais capped at 2 KB serialised.actionsaccepts 1–3 buttons. Invalid or extra fields return400; rendered labels are capped at 48 characters.- A/B tests take 2 to 10 arms. Per-arm
image_urlanddeep_linkare stored but not applied. significanceis computed only for exactly two test arms.- Use
platform_optionsfor validated iOS and Android presentation overrides. varsis accepted and ignored.delivery_policymay tighten this message's frequency cap and throttle within app limits. There is no route to recall a send in flight.- There is no
/v1route to upload an image. Use an HTTPS URL, or the console uploader. GET /messagessupports a stable cursor plus exactstatusandnamefilters.- Cancelling only works while a message is
Scheduled.
FAQ
Why did my immediate send return 404 instead of an empty success?
An immediate send that matches nothing is almost always a targeting mistake or an app with
no registered devices, so it is refused rather than recorded as a zero-audience campaign. A
scheduled send is not, because its audience is resolved when it fires.
How do I know how many people actually saw the notification?
Provider Accepted is what the provider took. Device Received and Confirmed Receipt
come from devices afterwards and are the honest answers. They lag, and they require your
app to be running the OpenPush SDK.
Can I personalize the image or deep link per A/B arm? No. Both are taken from the message-level fields regardless of arm. Vary copy instead.
My Liquid renders empty on preview but works on send. Why?
You are probably previewing dynamic content on audience-preview, which does not resolve
it. Use render-preview.
Does schedule_at respect the recipient's timezone?
Not by itself — it is one absolute instant. Combine it with delayed_option to add a
per-user axis, and pass epoch seconds so the instant is unambiguous.
What happens to a scheduled message if I change its segment's rules first?
The audience is resolved when the message fires, so it picks up the new rules. The
audience_est_at figure on the report is an estimate taken at create time, labelled as
such for exactly this reason.
Related
- API overview
- Apps, keys and settings
- Segments
- Templates and dynamic content
- Events and ingest
- Sending messages
- Personalization
- A/B testing
- Best-hour delivery
- Templates