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:
Code
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
Code
Example response
Code
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 |
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 a400naming 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 a400listing the names. image_urlgoes through the shared image validator (see Sending messages).- Localized text, platform options and dynamic-content references are validated
before saving. A template update increments
version.
Example request
Code
Example response
200 OK with the stored row:
Code
Note. On this route
datacomes 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, decodedatabefore 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
Code
Example response
Code
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
Code
Example response
Code
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:
Code
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-EUstores the table assales_eu, andGET …/dynamic-content/Sales-EUthen returns404. 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
Code
Example response
Code
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
Code
Example response
Code
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.
Code
Code
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
Code
Example response
Code
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
Code
Example response
Code
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 (
keyis 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.
Code
The parsed grid then goes through exactly the same rectangularity and quota checks as PUT.
Example request
Code
Example response
Identical to PUT — the saved table:
Code
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
Code
Example response
Code
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 resolvesdynamic_contentcorrectly.POST /v1/apps/{app_id}/audience-previewbuilds 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
PUTnor 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_urlmust 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.