# Templates and dynamic content


Two related resources for reusable message copy.

A **template** is reusable push content: a base title and body, optional localized
content, platform presentation defaults, image, deep link and custom data. You
reference it by ID when you create a message or use it in a journey.

**Dynamic content** is a per-app lookup table: a rectangular grid of strings you address from Liquid at render time, so one message can carry per-row copy (translations, tier-specific offers, store names) without you inlining every case.

Both resources validate Liquid at save time. A template that references a dynamic-content table which does not exist is rejected when you save it, not silently blanked when it sends.

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

---

## Authentication

Every endpoint on this page takes your app's REST API key:

```
X-OP-API-Key: <REST API key>
```

A REST key is scoped to the one app in the path. SDK keys do not authenticate these routes.

---

# Templates

## `GET /v1/apps/{app_id}/templates`

Lists every template in the app, newest first, plus the names of the built-in templates that ship with the server.

There is no pagination, filtering or search on this route — it returns the full set.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `app_id` | string | yes | The app id. |

### Example request

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

### Example response

```json
{
  "templates": [
    {
      "id": "tpl_01hq7m",
      "app": "app_3f9c",
      "name": "Streak reminder",
      "title": "{{ nm|there }}, your streak is at {{ streak_days }}",
      "body": "Finish today's lesson to keep it going.",
      "image_url": "https://cdn.example.com/streak.png",
      "deep_link": "myapp://lessons/today",
      "data": {"screen": "lessons"},
      "created_at": 1756512000.0,
      "sends_count": 412,
      "src_id": null
    }
  ],
  "builtin": ["welcome_flock", "comeback_1", "first_push_test"]
}
```

The three names in `builtin` are usable wherever a message references a template by name — on message create and on send-test — without you creating them first. They do **not** satisfy a journey's `send_push` node, which needs a `template_id` that resolves to a stored template row.

`GET /v1/apps/{app_id}/templates/{tpl_id}` reads one app-scoped stored template,
including its localized content, platform options and version. A missing or
other-app ID returns `404`.

### Errors

| Status | Body | Cause |
|---|---|---|
| 401 | `{"detail": "bad X-OP-API-Key"}` | Missing, wrong, or SDK-kind key. |

---

## `POST /v1/apps/{app_id}/templates`

Creates a template.

**Body**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Your label for the template. Not shown on the device. |
| `title` | string | yes | Notification title. May contain Liquid. |
| `body` | string | yes | Notification body. May contain Liquid. |
| `image_url` | string | no | Must be `https://` with a host, or a `/media/<app>/med_<id>.(png\|jpg\|gif)` path served by your own server. Max 2048 characters, no whitespace. |
| `deep_link` | string | no | URL or custom scheme opened on tap. |
| `data` | object | no | Custom key/value payload delivered with the notification. Must be a JSON object. |
| `languages` | object | no | Locale-code map: `{code: {title, body, subtitle, action_labels}}`. Every field is optional, and empty entries are dropped. Same shape as a message's [`languages`](02-messages.md#per-language-content) |
| `default_language` | string | no | Fallback locale; defaults to `en` |
| `platform_options` | object | no | Validated iOS and Android presentation defaults |

`name`, `title` and `body` are all required together — omitting any one of them is a single `400`.

**Validation performed at save time**

- Every Liquid-bearing field (`title`, `body`, `image_url`, `deep_link`) is compiled. A syntax error is a `400` naming the field, the message, the line and the column.
- Every `dynamic_content.<table>` reference found in those fields is resolved against this app's tables. Any missing table is a `400` listing the names.
- `image_url` goes through the shared image validator (see [Sending messages](../guides/sending-messages.md)).
- Localized text, platform options and dynamic-content references are validated
  before saving. A template update increments `version`.

### Example request

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/templates \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Streak reminder",
    "title": "{{ nm|there }}, your streak is at {{ streak_days }}",
    "body": "{{ dynamic_content.offers[tier].headline }}",
    "deep_link": "myapp://lessons/today",
    "data": {"screen": "lessons"}
  }'
```

### Example response

`200 OK` with the stored row:

```json
{
  "id": "tpl_01hq7m",
  "app": "app_3f9c",
  "name": "Streak reminder",
  "title": "{{ nm|there }}, your streak is at {{ streak_days }}",
  "body": "{{ dynamic_content.offers[tier].headline }}",
  "image_url": null,
  "deep_link": "myapp://lessons/today",
  "data": "{\"screen\":\"lessons\"}",
  "created_at": 1756598400.0,
  "sends_count": 0
}
```

> **Note.** On this route `data` comes back as the stored JSON **string**. The list route parses it into an object. If you round-trip a created template through your own code, decode `data` before using it.

### Errors

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "name, title and body required"}` | One of the three required fields is missing or empty. |
| 400 | `{"detail": "data must be an object"}` | `data` was sent as a string, array or number. |
| 400 | `{"detail": "Image URLs must be https:// — devices refuse anything else"}` | `image_url` is not HTTPS or a local media path. |
| 400 | `{"detail": "title: unexpected end of expression (line 1, column 14)"}` | Liquid failed to compile. The field name prefixes the message. |
| 400 | `{"detail": "unknown dynamic-content table(s): offers"}` | A referenced table does not exist in this app. |
| 401 | `{"detail": "bad X-OP-API-Key"}` | Bad key. |
| 404 | `{"detail": "unknown app"}` | No such app. |

---

## `PATCH /v1/apps/{app_id}/templates/{tpl_id}`

Updates one or more fields on an existing template. This is a partial update: fields you omit are left alone.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `app_id` | string | yes | The app id. |
| `tpl_id` | string | yes | The template id. |

**Body** — any of `name`, `title`, `body`, `image_url`, `deep_link`, `data`. At least one is required.

The patch is merged onto the stored row and the **merged result** is re-validated: Liquid is recompiled across every content field and dynamic-content references are re-resolved. Editing only `name` can therefore still fail validation if the stored `body` references a table you deleted in the meantime.

### Example request

```bash
curl -X PATCH https://app.openpush.ai/v1/apps/app_3f9c/templates/tpl_01hq7m \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body": "One lesson left today."}'
```

### Example response

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

The response does not echo the template. Re-read it with the list route if you need the new state.

### Errors

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "nothing to update"}` | Empty body, or no recognised field in it. |
| 400 | `{"detail": "data must be an object"}` | `data` is not a JSON object. |
| 400 | `{"detail": "body: unknown filter 'titlecase' (line 1, column 20)"}` | Liquid failed to compile on the merged row. |
| 400 | `{"detail": "unknown dynamic-content table(s): offers"}` | Referenced table missing. |
| 404 | `{"detail": "unknown template"}` | No template with that id in this app. |

---

## `DELETE /v1/apps/{app_id}/templates/{tpl_id}`

Deletes a template.

Deleting a template a live journey's `send_push` node points at is **not** blocked. The journey keeps running; when a run reaches that node the step is recorded as skipped and the run advances without sending. Check your journeys before deleting.

### Example request

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

### Example response

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

### Errors

| Status | Body | Cause |
|---|---|---|
| 404 | `{"detail": "unknown template"}` | No template with that id in this app. |

---

# Dynamic content

A dynamic-content table is a two-level map of strings: `{ "<row key>": { "<column>": "<value>" } }`. Every row must carry exactly the same column set as the first row — the grid is rectangular or it is rejected.

At render time each table is exposed under `dynamic_content` in both orientations:

```liquid
{{ dynamic_content.translations.welcome.es }}
{{ dynamic_content.translations.es.welcome }}
```

Both read the same cell. The transpose is built for you, so you can key by row or by column depending on which one your tag holds.

**Name rules.** A table name is trimmed, lowercased, and `-` is replaced with `_`. The result must match `^[a-z][a-z0-9_]{0,62}$` — start with a letter, then lowercase letters, digits or underscores, 63 characters maximum. The names `user`, `subscription`, `message`, `app` and `dynamic_content` are reserved, because they are already Liquid roots.

> **Gotcha.** Writes normalise the name; reads do not. `PUT …/dynamic-content/Sales-EU` stores the table as `sales_eu`, and `GET …/dynamic-content/Sales-EU` then returns `404`. Use the normalised form in every path.

**Size limits.**

| Limit | Value | Enforced on |
|---|---|---|
| Per table | 200 KB | The serialised JSON, and the raw CSV upload. Over → `413`. |
| Per app | 2 MB | Sum of all tables, computed as current total minus this table's old size plus its new size. Over → `413`. |
| Name length | 63 characters | Rejected as an invalid name. |

**Missing tables fail loudly.** Message compose, template save and render preview all resolve the tables a piece of copy references, and raise if any are absent. A missing table never silently renders empty. A missing *row or column inside an existing table* is different: Liquid's lax variable handling yields an empty string, so add `| default:` where a gap is possible.

**Snapshot timing.** References are checked when you compose a message; the *values* are copied at the moment delivery starts. A message scheduled for tomorrow renders against tomorrow's table contents, not today's.

---

## `GET /v1/apps/{app_id}/dynamic-content`

Lists the app's tables without expanding their grids, plus the app's storage quota and current usage.

### Example request

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

### Example response

```json
{
  "tables": [
    {
      "id": "dyn_01hq7p",
      "name": "offers",
      "size_bytes": 184,
      "created_at": 1756512000.0,
      "updated_at": 1756598400.0
    },
    {
      "id": "dyn_01hq7q",
      "name": "translations",
      "size_bytes": 341,
      "created_at": 1756512000.0,
      "updated_at": 1756512000.0
    }
  ],
  "quota_bytes": 2097152,
  "used_bytes": 525
}
```

---

## `GET /v1/apps/{app_id}/dynamic-content/{name}`

Returns one table with its full grid.

**Path parameters**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `app_id` | string | yes | The app id. |
| `name` | string | yes | The table name, in its normalised form. |

### Example request

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c/dynamic-content/offers \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

### Example response

```json
{
  "id": "dyn_01hq7p",
  "app": "app_3f9c",
  "name": "offers",
  "data": {
    "starter":   {"headline": "A small welcome", "cta": "Open now"},
    "returning": {"headline": "Welcome back",    "cta": "See what changed"}
  },
  "size_bytes": 184,
  "created_at": 1756512000.0,
  "updated_at": 1756598400.0
}
```

### Errors

| Status | Body | Cause |
|---|---|---|
| 404 | `{"detail": "unknown dynamic-content table"}` | No table by that exact name. Check the normalisation rules above. |

---

## `PUT /v1/apps/{app_id}/dynamic-content/{name}`

Creates the table, or **replaces it wholesale**. There is no partial row update: whatever you send is the entire new grid.

**Body** — either a wrapper or the bare grid; both are accepted.

```json
{"data": {"starter": {"headline": "A small welcome", "cta": "Open now"}}}
```

```json
{"starter": {"headline": "A small welcome", "cta": "Open now"}}
```

**Grid rules**

| Rule | Failure |
|---|---|
| The grid is a non-empty object | `400 "data must be a non-empty object of row keys"` |
| Every row key is a non-empty string and every row is an object | `400 "every row needs a non-empty key and an object of columns"` |
| No two rows share a key after trimming | `400 "duplicate row key 'starter'"` |
| Every row has at least one non-empty column name | `400 "row 'starter' has no columns"` |
| Every row has the same column set as the first row | `400 "row 'returning' does not have the same columns as row 1"` |

Column order is pinned to the first row. `null` values are coerced to `""`. Column names are trimmed; blank ones are dropped before the checks run.

### Example request

```bash
curl -X PUT https://app.openpush.ai/v1/apps/app_3f9c/dynamic-content/offers \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "starter":   {"headline": "A small welcome", "cta": "Open now"},
      "returning": {"headline": "Welcome back",    "cta": "See what changed"}
    }
  }'
```

### Example response

```json
{
  "id": "dyn_01hq7p",
  "app": "app_3f9c",
  "name": "offers",
  "data": {
    "starter":   {"headline": "A small welcome", "cta": "Open now"},
    "returning": {"headline": "Welcome back",    "cta": "See what changed"}
  },
  "size_bytes": 184,
  "updated_at": 1756598400.0
}
```

Replacing an existing table keeps its `id` and `created_at`.

### Errors

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "name must start with a letter and contain only lowercase letters, numbers, or _"}` | The path name is reserved or fails the pattern. |
| 400 | *(grid rule messages above)* | The grid is not rectangular, or a key is bad. |
| 413 | `{"detail": "dynamic-content table exceeds 200 KB"}` | Serialised grid over the per-table cap. |
| 413 | `{"detail": "dynamic-content storage exceeds 2 MB for this app"}` | The app would go over its total quota. |

---

## `DELETE /v1/apps/{app_id}/dynamic-content/{name}`

Deletes the table.

Nothing checks whether a saved template or a scheduled message still references it. The deletion succeeds, and the next compose or template save that names the table fails with `unknown dynamic-content table(s)`. A message already in flight uses the snapshot taken when its delivery started.

### Example request

```bash
curl -X DELETE https://app.openpush.ai/v1/apps/app_3f9c/dynamic-content/offers \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

### Example response

```json
{"deleted": "offers"}
```

### Errors

| Status | Body | Cause |
|---|---|---|
| 404 | `{"detail": "unknown dynamic-content table"}` | No table by that name. |

---

## `POST /v1/apps/{app_id}/dynamic-content/{name}/csv`

Uploads a CSV as the table's new contents. Like `PUT`, this is a full replacement.

**Request** — `multipart/form-data` with a single field named exactly `file`.

**CSV format**

- The **first column is the row key**; every remaining column is a value column.
- The header row needs at least two cells. The first cell can be anything (`key` is conventional); the rest become the column names and must be non-blank and unique.
- A UTF-8 byte-order mark is stripped. Files must decode as UTF-8.
- Blank lines are skipped. Rows shorter than the header are padded with empty strings; extra cells past the header are discarded.
- An empty or duplicate row key is an error naming the line number.

```csv
key,headline,cta
starter,A small welcome,Open now
returning,Welcome back,See what changed
```

The parsed grid then goes through exactly the same rectangularity and quota checks as `PUT`.

### Example request

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/dynamic-content/offers/csv \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -F "file=@offers.csv"
```

### Example response

Identical to `PUT` — the saved table:

```json
{
  "id": "dyn_01hq7p",
  "app": "app_3f9c",
  "name": "offers",
  "data": {
    "starter":   {"headline": "A small welcome", "cta": "Open now"},
    "returning": {"headline": "Welcome back",    "cta": "See what changed"}
  },
  "size_bytes": 184,
  "updated_at": 1756598400.0
}
```

### Errors

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "multipart field 'file' is required"}` | No `file` part, or it carries no filename. |
| 400 | `{"detail": "CSV is empty"}` | No header row. |
| 400 | `{"detail": "CSV needs a key column followed by a value column"}` | Fewer than two header cells, or a blank header cell. |
| 400 | `{"detail": "CSV header contains duplicate column names"}` | Repeated header name. |
| 400 | `{"detail": "line 4: duplicate row key 'starter'"}` | Repeated row key. |
| 400 | `{"detail": "line 7: row key is empty"}` | Blank first cell on a non-blank line. |
| 400 | — | The upload is not valid UTF-8. |
| 413 | `{"detail": "CSV exceeds 200 KB"}` | Raw upload over the per-table cap. |
| 413 | `{"detail": "dynamic-content storage exceeds 2 MB for this app"}` | App quota exceeded. |

---

## `GET /v1/apps/{app_id}/dynamic-content/{name}/csv`

Downloads the table as CSV. Streams `text/csv` with `Content-Disposition: attachment; filename="<name>.csv"`.

The header row is the literal `key` followed by the column names from the first row, and line endings are `\n`. The output of this route is accepted by the upload route unchanged, so download → edit → upload is a supported round trip.

### Example request

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c/dynamic-content/offers/csv \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -o offers.csv
```

### Example response

```csv
key,headline,cta
starter,A small welcome,Open now
returning,Welcome back,See what changed
```

### Errors

| Status | Body | Cause |
|---|---|---|
| 404 | `{"detail": "unknown dynamic-content table"}` | No table by that name. |

---

## Notes and limits

- **Previewing dynamic content.** Use `POST /v1/apps/{app_id}/render-preview`, which resolves `dynamic_content` correctly. `POST /v1/apps/{app_id}/audience-preview` builds its render context **without** dynamic content, so `{{ dynamic_content.* }}` comes back empty there. That is a known asymmetry, not a data problem.
- **No pagination anywhere on this page.** Both list routes return everything.
- **No rate limit** applies to any of these endpoints.
- **Uploads are full replacements.** Neither `PUT` nor the CSV upload merges into an existing grid. To change one row, read the table, edit the object, and write it back.
- **Media upload is console-only.** There is no API route that uploads an image; `image_url` must be an HTTPS URL, or a `/media/...` path produced by uploading through the console.
- Deleting a template or a table is not blocked by anything referencing it.

---

## FAQ

**Can I use Liquid in a template's `data` payload?**
Not usefully. Custom-data values are delivered literally — a `data` value containing `{{ first_name }}` arrives on the device as that exact text. Only titles, bodies, image URLs, deep links, action labels and the iOS subtitle render.

**What happens if a row key in my table is missing at send time?**
The reference resolves to an empty string, and the message still sends. Guard it with `{{ dynamic_content.offers[tier].headline | default: "Something for you" }}`.

**How do I know how much of my 2 MB quota is left?**
`GET /v1/apps/{app_id}/dynamic-content` returns `quota_bytes` and `used_bytes` on every call.

**Are the built-in templates editable?**
No. `welcome_flock`, `comeback_1` and `first_push_test` ship with the server and are referenced by name. To customise one, create your own template with the copy you want.

**Does changing a template change messages already sent?**
No. A message copies the content it needs when it is composed; delivery reports and past sends are unaffected by a later edit.

---

## Related

- [Templates](../guides/templates.md)
- [Personalization](../guides/personalization.md)
- [Messages API](02-messages.md)
- [Journeys API](07-journeys.md)
- [API overview](00-overview.md)
