# Templates


A template is reusable message content — a name, a title, a body, and optionally an image, a deep
link and a custom data payload. Reference it from a send and it fills in whatever the send does not
supply itself. Liquid inside a template is validated when you save it, so a broken expression is
caught by the person editing the copy rather than by the campaign that uses it.

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

## When to use this page

Use templates for content you send repeatedly or content someone other than an engineer maintains:
a welcome message, a win-back nudge, a receipt. Journeys require them — a journey's send-push step
references a template by id and will not go live without one.

If the content is one-off, just put the title and body in the send.

## Prerequisites

- The app's REST API key.
- If the template references dynamic content, the tables must already exist. Saving a template that
  references a missing table fails.

## Template fields

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | **yes** | Human label. Also usable as the reference on a send |
| `title` | string | **yes** | Liquid-enabled |
| `body` | string | **yes** | Liquid-enabled |
| `image_url` | string | no | Must be `https://` or a media path OpenPush hosts. Liquid-enabled |
| `deep_link` | string | no | Liquid-enabled |
| `data` | object | no | Custom payload, merged into the send |

Read-only fields on a stored template:

| Field | Meaning |
|---|---|
| `id` | `tpl_…`, assigned on create |
| `created_at` | Creation timestamp |
| `sends_count` | How many messages have used this template |
| `src_id` | Set on templates pulled in from a OneSignal history import; `null` on native templates |

## Creating a template

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/templates \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Win back — 7 day",
        "title": "{{ nm }}, your streak is waiting",
        "body": "Your crew held the line while you were gone. {{ reward | default: \"A reward\" }} is ready.",
        "deep_link": "acme://home",
        "data": {"campaign_family": "winback"}
      }'
```

```json
{
  "id": "tpl_6a3c04e18f27",
  "app": "acme-app",
  "name": "Win back — 7 day",
  "title": "{{ nm }}, your streak is waiting",
  "body": "Your crew held the line while you were gone. {{ reward | default: \"A reward\" }} is ready.",
  "image_url": null,
  "deep_link": "acme://home",
  "data": "{\"campaign_family\": \"winback\"}",
  "created_at": 1788044412.87,
  "sends_count": 0
}
```

All three of `name`, `title` and `body` are required — omitting any is
`400 name, title and body required`.

### Validation at save time

Two checks run before the row is written, and both fail the request with a `400`:

1. **Liquid compiles.** Every Liquid-enabled field is parsed. A syntax error comes back naming the
   field, the line and the column.
2. **Referenced dynamic content exists.** If the copy mentions `dynamic_content.offers` and there
   is no `offers` table on this app, the save is rejected. This is the same check the send path
   runs, moved earlier.

`image_url` is validated too — it must be `https://` with a host or a media path OpenPush hosts,
no whitespace, at most 2048 characters.

### Creating one in the console

The console's template editor writes the same rows through the same validation. Use it when the
person maintaining copy is not the person calling the API. There is nothing a console-created
template can do that an API-created one cannot.

## Listing templates

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

```json
{
  "templates": [
    {"id": "tpl_6a3c04e18f27", "name": "Win back — 7 day", "title": "…", "body": "…",
     "image_url": null, "deep_link": "acme://home", "data": {"campaign_family": "winback"},
     "created_at": 1788044412.87, "sends_count": 14, "src_id": null}
  ],
  "builtin": ["welcome_flock", "comeback_1", "first_push_test"]
}
```

Newest first. The `builtin` array lists the built-in templates that ship with the server — see
below.

## Using a template in a message

Reference it with `template` or `template_id`. **Either the template's id or its name works.**

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "template": "tpl_6a3c04e18f27",
        "include_segments": ["seg_0a4c1d92be77"]
      }'
```

### How merging works

The send body wins, **field by field**. Anything you leave out falls back to the template.

```json
{
  "template": "Win back — 7 day",
  "title": "One more day and your streak resets"
}
```

That send uses your title and the template's body, deep link and data. There is no partial merge
inside `data` — supplying `data` on the send replaces the template's `data` object entirely.

Because Liquid is kept as source until per-device rendering, a template's `{{ nm }}` still resolves
against each recipient's tags, not against anything fixed at compose time.

The same referencing works on `POST /v1/apps/{app_id}/send-test`.

Every delivery that used a stored template increments its `sends_count`.

### Errors

| Response | Cause |
|---|---|
| `404 unknown template '<ref>'` | The reference matched neither a stored template id or name, nor a built-in |
| `400 need title+body or a known template` | The resolved template did not supply both a title and a body, and the send body did not fill the gap |

## Built-in templates

Three templates ship with the server and are referenceable by name without creating anything:

| Name | Purpose |
|---|---|
| `welcome_flock` | A first-run welcome |
| `comeback_1` | A win-back nudge, with Liquid placeholders |
| `first_push_test` | An integration smoke test |

They supply **only a title and a body**. A built-in cannot carry an image, a deep link or a data
payload — if you need those, create a real template. Built-ins are not editable and do not appear in
the `templates` array, only in `builtin`.

## Updating and deleting

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/acme-app/templates/tpl_6a3c04e18f27 \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body": "Your crew held the line. Your reward chest expires in 48 hours."}'
```

```json
{"id": "tpl_6a3c04e18f27", "updated": true}
```

Any of `name`, `title`, `body`, `image_url`, `deep_link` and `data` can be patched, and the same
Liquid and dynamic-content validation runs again. An empty patch is `400 nothing to update`; an
unknown id is `404 unknown template`.

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

```json
{"id": "tpl_6a3c04e18f27", "deleted": true}
```

> **Deleting a template does not rewrite history.** Messages already sent keep their rendered
> content and their `template_id` reference. But a **journey** whose send-push node points at a
> deleted template will fail validation the next time it is activated, and templates you send by
> *name* will start returning `404 unknown template` from your backend. Check both before deleting.

## Imported OneSignal templates

Templates pulled in by the console's OneSignal history import carry a `src_id`: the provider's own
stable identifier for that template.

That field exists so a re-run of the import **updates the existing row rather than creating a
duplicate**. If you pull your OneSignal archive twice — which you will, because the first pull is
never quite complete — you end up with one template per source template, not two.

Things to know about `src_id`:

- It is **read-only**. There is no way to set or change it through the API; it is written only by
  the importer.
- Native templates created through the API or the console have `src_id: null`.
- Imported templates are ordinary templates in every other respect: editable, deletable, usable in
  sends and journeys.

The OneSignal history import itself is a console action under the app's migration screens, not a
`/v1` route. See [migrate from OneSignal](migrate-from-onesignal.md).

## Limits

- **Per-language content is stored on the template.** A send that references the template uses
  the template's `languages` (titles, bodies, iOS subtitles and button labels) unless the send has its
  own `languages`. That map replaces the template's whole map; entries are not merged. A send that
  overrides the template's `title` or `body` gets none of the template's translations, because the
  override goes to every device.
- **Templates have no A/B variants**, no TTL, no priority, no collapse key and no scheduling. All of
  those are message-level.
- **No template versioning or history.** A patch overwrites; there is no rollback.
- **No duplicate-template route.** Read one and create a new one.
- **No folders, tags or categories** on templates.
- `data` on a template is replaced wholesale by `data` on a send, never merged key by key.
- `image_url` follows the same rules as on a message: `https://` only, 2048 characters, no
  whitespace.

## FAQ

**Should I reference templates by id or by name?**
By id from code — names are editable and someone will rename one. By name is convenient for quick
manual sends and for the built-ins.

**Can two templates share a name?**
Nothing prevents it, and reference-by-name then resolves ambiguously. Keep names unique.

**Does editing a template change messages already scheduled with it?**
No. A template is resolved into the message when you create it, so a scheduled send carries the copy
the template held at compose time. Editing the template afterwards affects only messages created
after the edit. (Dynamic-content *values* are the exception: those are snapshotted when delivery
begins — see [personalization](personalization.md).)

**Why did my template save fail with a dynamic content error?**
The copy references a table that does not exist on this app. Create the table first, then save the
template. See [personalization](personalization.md).

**Can a journey use a built-in template?**
No. A journey's send-push node needs a `template_id` that resolves to a stored template row on the
app.

**Where do I see how often a template is used?**
`sends_count` on the template row counts messages delivered with it.

## Related

- [Sending messages](sending-messages.md) — how a template merges into a send
- [Personalization](personalization.md) — Liquid and dynamic content inside a template
- [Journeys](journeys.md) — where templates are mandatory
- [Migrate from OneSignal](migrate-from-onesignal.md) — imported templates and `src_id`
- [Templates and dynamic content API reference](../api-handbook/05-templates-dynamic-content.md)
