# 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](06-events-ingest.md).

---

## `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-Key` header 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 returns `409`.

Unknown top-level message fields and unsupported audience or platform fields return `400`.
Check accepted fields against the [API reference](/docs/api) 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](00-overview.md#image-urls) |
| `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.

```json
{
  "title": "Match ready",
  "body": "Choose what to do next.",
  "actions": [
    {"id": "join", "label": "Join"},
    {"id": "later", "label": "Later", "icon": "clock"}
  ]
}
```

Labels can be translated per language with `languages.<code>.action_labels` (see
[Per-language content](#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](../guides/sdk-ios.md#action-buttons-from-the-extension) shows the
notification service extension call needed to display the buttons. The
[Android SDK guide](../guides/sdk-android.md) 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`](05-templates-dynamic-content.md).

### 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.

```json
{
  "title": "Your streak is alive",
  "body": "Three runs this week. Keep going.",
  "subtitle": "Week 4",
  "default_language": "en",
  "actions": [
    {"id": "play", "label": "Play now"},
    {"id": "later", "label": "Later"}
  ],
  "languages": {
    "es": {"title": "Tu racha sigue viva", "body": "Tres carreras esta semana.",
           "subtitle": "Semana 4", "action_labels": {"play": "Jugar ahora", "later": "Luego"}},
    "pt": {"title": "Sua sequência continua", "body": "Três corridas nesta semana."}
  }
}
```

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 a `400`, never a
  match-everything. An alias label containing no `A-Z a-z 0-9 _ . -` characters is also a
  `400`.
- 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:

```json
{
  "target": {"aliases": {"label": "account", "ids": ["42", "43"]}},
  "filters": [
    {"field": "tag", "key": "plan", "op": "is", "value": "pro"},
    {"field": "country", "op": "is", "value": "US"},
    {"operator": "or"},
    {"field": "app_version", "op": "in", "value": ["2.4", "2.5"]}
  ]
}
```

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`.

```json
{
  "include_segments": ["seg_51ba7fd0c48e"],
  "exclude_segments": ["seg_9c3e70a145bd"],
  "target": {"platforms": ["ios", "android"]}
}
```

### 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_at` given as a **date string** is parsed in the
> **server's** local timezone, so `"2026-09-01T09:00"` means different instants under
> different container `TZ` settings. 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](../guides/best-hour-delivery.md).

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](11-backend-api.md) 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_url` and `deep_link` are 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](#post-v1appsapp_idmessagesmessage_idpromote)
and [A/B testing](../guides/ab-testing.md).

### 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`](#notification-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:

```json
{
  "platform_options": {
    "ios": {"sound": "chime.caf", "badge": 3, "interruption": "active"},
    "android": {"channel": "offers", "accent": "#3366CC", "group": "promotions"}
  }
}
```

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](../guides/sdk-android.md#android-rendering-options-in-the-payload).

### 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](../guides/personalization.md).

### 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:

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Week 12 nudge",
    "title": "Your streak is alive",
    "body": "Three runs this week. Keep going.",
    "include_segments": ["seg_51ba7fd0c48e"]
  }'
```

A personalized, localized, scheduled send with delivery options:

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Saturday long run",
    "title": "Ready, {{ nm | default: \"runner\" }}?",
    "body": "You have {{ badges }} {{ badges | pluralize: \"badge\" }} waiting.",
    "default_language": "en",
    "languages": {
      "es": {"title": "¿Listo, {{ nm | default: \"corredor\" }}?", "body": "Te esperan {{ badges }}."}
    },
    "image_url": "https://cdn.example.com/promo/long-run.png",
    "deep_link": "runnerclub://plans/long-run",
    "include_segments": ["seg_51ba7fd0c48e"],
    "exclude_segments": ["seg_9c3e70a145bd"],
    "schedule_at": 1757152800,
    "delayed_option": "timezone",
    "delivery_time_of_day": "08:30",
    "ttl": 86400,
    "priority": "high",
    "collapse_key": "long-run",
    "custom_data": {"plan": "half-marathon"},
    "data": {"campaign": "week-12"},
    "platform_options": {
      "ios": {"interruption": "time-sensitive"},
      "android": {"channel": "coaching"}
    },
    "actions": [{"id": "open_plan", "label": "Open plan"}]
  }'
```

A two-arm A/B test with best-hour delivery and auto-promotion:

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Streak copy test",
    "title": "Your streak is alive",
    "body": "Three runs this week.",
    "include_segments": ["seg_51ba7fd0c48e"],
    "delayed_option": "last-active",
    "variants": [
      {"name": "Encouraging", "title": "Your streak is alive", "body": "Three runs this week. Keep going."},
      {"name": "Direct", "title": "One run to go", "body": "Finish the week strong."}
    ],
    "ab": {"test_pct": 30, "auto": {"enabled": true, "after_h": 12, "min_per_arm": 250}}
  }'
```

### Response

**Immediate send** — the call blocks through the fan-out and returns the outcome.

```json
{
  "id": "msg_9f21c4a70b3d",
  "title": "Your streak is alive",
  "status": "Delivered",
  "sent": 41822,
  "provider_accepted": 41615,
  "failed": 207,
  "funnel": {
    "Audience": 48310,
    "Capped": 5104,
    "Held": 1384,
    "Remaining": 41822,
    "Sent": 41822,
    "Failed": 207,
    "Retryable": 61,
    "Delivered": 41615
  },
  "ttl_s": 86400,
  "priority": "high",
  "collapse_key": "long-run",
  "delayed_option": null,
  "delivery_time_of_day": null,
  "note": "provider_accepted = FCM took it; device receipts arrive via /v1/ingest",
  "report": "/v1/apps/runner-club/messages/msg_9f21c4a70b3d"
}
```

**Scheduled send** — nothing is delivered yet.

```json
{
  "id": "msg_2c07be914da5",
  "app": "runner-club",
  "status": "Scheduled",
  "schedule_at": 1757152800,
  "title": "Saturday long run",
  "ttl_s": 86400,
  "priority": "high",
  "collapse_key": "long-run",
  "delayed_option": "timezone",
  "delivery_time_of_day": "08:30",
  "report": "/v1/apps/runner-club/messages/msg_2c07be914da5"
}
```

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](00-overview.md#image-urls) |
| `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 `404` applies to **immediate** sends only. A scheduled send with an
  empty audience today is created successfully, because the audience is resolved when it
  fires.
- `403` on 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:

1. Give every campaign a distinct `name`.
2. On a timeout, call `GET /v1/apps/{app_id}/messages` and look for that name before
   retrying.
3. For large sends, prefer `schedule_at` a minute or two out: a scheduled create is cheap
   to verify with `GET` and cheap to undo with `DELETE`.

---

## `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.

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

```json
{
  "messages": [
    {
      "id": "msg_9f21c4a70b3d",
      "name": "Week 12 nudge",
      "title": "Your streak is alive",
      "status": "Delivered",
      "created_by": "api",
      "sent_at": 1757066400.0,
      "schedule_at": null,
      "Sent": 41822,
      "Provider Accepted": 41615,
      "Device Received": 38904,
      "Confirmed Receipt": 37211,
      "Clicked": 1702,
      "Failed": 207,
      "Audience": 48310,
      "Capped": 5104,
      "CTR": "4.1%"
    }
  ]
}
```

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 |

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

**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`.

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

```json
{"id": "msg_2c07be914da5", "status": "Canceled"}
```

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 |

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/messages/msg_9f21c4a70b3d/promote \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"winner":"B"}'
```

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.

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/send-test \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Test push","body":"If you can read this, APNs is configured."}'
```

```json
{
  "id": "msg_74b0c8e12a9f",
  "is_test": true,
  "devices": 2,
  "funnel": {
    "Audience": 2, "Capped": 0, "Held": 0, "Remaining": 2,
    "Sent": 2, "Failed": 0, "Retryable": 0, "Delivered": 2
  },
  "note": "test sends do not appear in Sent Messages"
}
```

**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](03-subscriptions-users.md).

**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 |

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/audience-preview \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "include_segments": ["seg_51ba7fd0c48e"],
    "delayed_option": "last-active",
    "title": "Ready, {{ nm | default: \"runner\" }}?",
    "body": "Three runs this week."
  }'
```

```json
{
  "at": 1757066400.0,
  "audience": 48310,
  "capped": 5104,
  "held": 0,
  "timed": 43206,
  "tz_known": 41880,
  "avg_pct": 62,
  "no_data": 1326,
  "fallback_hour": 19,
  "remaining": 0,
  "platforms": {"android": 30112, "ios": 18198},
  "languages": {"en": 39204, "es": 3110, "": 892},
  "settings": {"app": "runner-club", "quiet_enabled": 0, "freq_cap": 10, "freq_window_h": 24},
  "preview_title": "Ready, runner?",
  "preview_body": "Three runs this week.",
  "render_errors": []
}
```

| 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](#post-v1appsapp_idrender-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 |

```bash
curl -X POST https://app.openpush.ai/v1/apps/runner-club/render-preview \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tags": {"nm": "Ada", "badges": "3", "tier": "gold"},
    "language": "es",
    "custom_data": {"plan": "half-marathon"},
    "title": "{{ dynamic_content.translations.welcome[user.language] }}",
    "body": "{{ nm }}, tienes {{ badges }} {{ badges | pluralize: \"insignia\" }}."
  }'
```

```json
{
  "rendered": {
    "title": "Bienvenida",
    "body": "Ada, tienes 3 insignias.",
    "image_url": "",
    "deep_link": "",
    "subtitle": ""
  },
  "errors": [],
  "dynamic_content": ["translations"]
}
```

| 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](01-apps-keys-settings.md#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](03-subscriptions-users.md).

## 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_data` is capped at **2 KB** serialised.
- `actions` accepts **1–3** buttons. Invalid or extra fields return `400`; rendered
  labels are capped at 48 characters.
- A/B tests take **2 to 10** arms. Per-arm `image_url` and `deep_link` are stored but not
  applied.
- `significance` is computed only for exactly **two** test arms.
- Use `platform_options` for validated iOS and Android presentation overrides.
- `vars` is accepted and ignored.
- `delivery_policy` may tighten this message's frequency cap and throttle within app
  limits. There is no route to recall a send in flight.
- There is no `/v1` route to upload an image. Use an HTTPS URL, or the console uploader.
- `GET /messages` supports a stable cursor plus exact `status` and `name` filters.
- 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](00-overview.md)
- [Apps, keys and settings](01-apps-keys-settings.md)
- [Segments](04-segments.md)
- [Templates and dynamic content](05-templates-dynamic-content.md)
- [Events and ingest](06-events-ingest.md)
- [Sending messages](../guides/sending-messages.md)
- [Personalization](../guides/personalization.md)
- [A/B testing](../guides/ab-testing.md)
- [Best-hour delivery](../guides/best-hour-delivery.md)
- [Templates](../guides/templates.md)
