Personalization
Personalization renders each device's copy individually, using that person's tags, language, country and identity, plus lookup tables you upload. It is written in Liquid, and it happens at the last possible moment — after per-language copy has been chosen, once per device, in the send pipeline.
All examples use https://app.openpush.ai, the OpenPush API base URL.
When to use this page
Reach for personalization when the same message should say something different to each person: their name, their tier, their city's translation of a phrase, the offer their cohort qualifies for. If the difference is per-language rather than per-person, use per-language content instead — it is simpler and it composes with Liquid.
Prerequisites
- Tags on your users. Tags are set by the SDKs (
addTag/addTags) or arrive with a device registration. Tag values are strings on every SDK; there are no numeric or date tag types. - The app's REST API key for previewing and for managing dynamic content.
The basics
Liquid expressions go directly in title, body, image_url and deep_link, in every
per-language variant of title and body, in every A/B arm's copy, in action button labels, and in
the iOS subtitle.
Code
Every tag is available as a top-level variable, so a tag named streak_days is simply
{{ streak_days }}.
The render-time namespace
| Name | Contents |
|---|---|
| (every tag key) | Each of the user's tags, flattened to a top-level variable |
user | tags, language, country, external_id, timezone |
subscription | id, platform, language, app_version, external_id |
message | id, name, custom_data |
app | id, name |
dynamic_content | Your uploaded lookup tables — see below |
nm, name, nickname | All three bound to the same display-name value |
Notes that will save you a debugging session:
- Empty-string and null tag values are dropped from the namespace, matching the SDK convention
that an empty string deletes a tag. A tag set to
""behaves as absent, not as an empty string. user.languageis normalised: lowercased, underscores turned to hyphens, first subtag only, with the obsolete Java codes remapped (iw→he,in→id,ji→yi).subscription.languageis the raw value the device reported.user.tagsis the whole map, so{{ user.tags['order status'] }}works for keys with spaces that cannot be written as a bare identifier.message.custom_datais whatever you passed in the send'scustom_dataobject — that is the supported way to pass message-level variables.
The display name
nm, name and nickname all resolve to the same thing: the first non-empty of the tags nm,
name or nickname; failing that, the user's external ID; failing that, an empty string.
The external-ID fallback is treated as "not really a name". So on a user with no name tag but an
external ID of user_8412:
{{ nm }}rendersuser_8412{{ nm | default: "Runner" }}rendersRunner
That is deliberate — a bare reference gives you something, and an explicit fallback still wins.
Defaults and fallbacks — two spellings
Both of these do the same thing:
Code
The second is the legacy spelling, rewritten internally to the first. It refuses to interfere with
real filters: if the text after the pipe contains a colon, or its leading word is a registered
filter name, the expression is left exactly as written. So {{ price | times: 2 }} and
{{ body | strip }} are never mangled. The rewrite is idempotent and the fallback text is encoded
so embedded quotes survive.
Prefer the explicit | default: form in new work. Use fallbacks liberally: a missing variable
renders as empty, not as an error, so Hello {{ first_name }}! on a user with no name tag ships
as Hello !.
Supported Liquid
OpenPush runs a real Liquid engine — python-liquid 2.x — not a hand-rolled substitution. HTML escaping is off (push payloads are not HTML) and unknown filters are an error rather than a silent no-op.
Tags
The standard Liquid tag set is available apart from the six disabled tags below — the engine
removes those and adds nothing else. These are the tags exercised and confirmed by OpenPush's
own test suite: output {{ }}, {% if %} / {% elsif %} / {% else %} / {% endif %},
{% unless %}, {% case %} / {% when %}, {% assign %}, {% for %} with limit:, offset:
and {% else %}, forloop.index and forloop.last, {% raw %}, whitespace control {{- -}},
the contains operator, and and / or.
Disabled, each raising a syntax error when you save the message: include, render,
tablerow, cycle, increment, decrement. There is no file system to include from and no
per-render state to increment.
Filters
The standard Liquid filter set is available. These are the ones exercised and confirmed:
default: · date: · round · upcase · downcase · capitalize · replace: ·
replace_first: · strip · lstrip · rstrip · strip_html · truncate: · truncatewords: ·
prepend: · append: · url_encode · escape · plus: · minus: · times: · divided_by: ·
modulo: · size · first · last · join: · split: · where:
Three behaviours that surprise people:
{{ "abcdef" | truncate: 5 }}→ab...— the ellipsis counts toward the length.{{ 7 | divided_by: 2 }}→3— integer division when both operands are integers.{{ "a b" | url_encode }}→a+b— form encoding, not percent encoding.
One custom filter is added:
Code
pluralize returns the singular when the value equals one — including the string "1" — and
otherwise the explicit plural, or the singular with an s appended. Non-numeric input takes the
plural.
Resource ceilings
Rendering is bounded so one bad template cannot stall a fan-out: loops run at most 100 iterations, at most 100 local variables per render, output is capped at 8192 bytes, and context depth and block nesting are capped at 20.
Where Liquid applies, and the caps
Every one of these is compiled and validated when you create the message or save the template:
title · body · image_url · deep_link · every languages.{code}.title and .body · every
A/B arm's title, body and per-language forms · every action button label · the iOS subtitle
A compile failure fails the create call with 400 {field}: {message} (line L, column C), naming
the field.
Render-time caps, applied per device after rendering:
| Field | Cap |
|---|---|
title | 512 characters |
body | 2048 characters |
image_url | 2048 characters |
deep_link | 2048 characters |
Action button label | 48 characters |
iOS subtitle | 512 characters |
| Whole rendered payload | 4096 bytes |
Two ordering facts worth internalising:
- Language selection happens before rendering, so
{{ }}resolves inside translated copy rather than only in the default language. - Only action button labels render. Other values inside
datastay literal, so a data field whose value is the string"{{ first_name }}"is delivered exactly like that. This is intentional: your custom data is your app's, not a template.
Error handling
Personalization is designed so that a bad expression degrades rather than dropping a send.
- Compile errors are caught at save time. You get a
400naming the field, the line and the column, before anything is sent. - Rendering never raises. If a render fails at send time, the field is emitted as its raw
authored source — the literal
{{ … }}text — rather than as an empty field or a 500. It looks wrong, visibly, which is the point. - Missing variables are lax.
<{{ missing }}>renders as<>. - A field over its cap is truncated and counted.
- An over-size payload is truncated deterministically (body first, then title) and flagged with
op_render_truncated. If it is still over 4 KB, the delivery is refused rather than mangled further. - A payload whose title and body both render empty is refused.
- A message stored before stricter validation existed still sends: a field that no longer compiles degrades to its literal source rather than blocking the campaign.
render_errors
Every one of those events increments a counter on the message, surfaced on the report as
render_errors:
Code
A non-zero render_errors on a delivered campaign means some devices got degraded copy. It does
not tell you which devices or which field — treat it as a smoke alarm and reproduce with
render-preview.
Previewing a render
POST /v1/apps/{app_id}/render-preview renders every personalization target against a sample
device you describe.
Code
Code
The body may supply a whole subscription object, or the flat overrides tags, external_id,
language and country; plus message_id, name and custom_data to populate the message
namespace; plus the sources title, body, image_url, deep_link and subtitle. The errors
array names the field and the message for anything that failed, and dynamic_content lists which
tables the sources referenced.
Use
render-preview, notaudience-preview, to check dynamic content. The audience preview builds its render context without dynamic content, so{{ dynamic_content.* }}silently renders empty there. That preview exists to tell you how many devices you are about to reach;render-previewis the one that tells you what they will read.
Dynamic content
Dynamic content is a named lookup table you upload once and reference from any message or template. The classic uses are translations keyed by language, and offer copy keyed by cohort.
A table is a two-level map of strings: {row key: {column: value}}.
Code
Reference it either way round — the transpose is materialised for you:
Code
| Route | Purpose |
|---|---|
GET /v1/apps/{app_id}/dynamic-content | List tables, with per-table sizes and the app's quota |
GET /v1/apps/{app_id}/dynamic-content/{name} | One table with its data |
PUT /v1/apps/{app_id}/dynamic-content/{name} | Create or replace a table |
DELETE /v1/apps/{app_id}/dynamic-content/{name} | Delete a table |
POST /v1/apps/{app_id}/dynamic-content/{name}/csv | Upload as CSV |
GET /v1/apps/{app_id}/dynamic-content/{name}/csv | Download as CSV |
Table rules
- Names are trimmed, lowercased, hyphens turned to underscores, and must match
^[a-z][a-z0-9_]{0,62}$. The namesuser,subscription,message,appanddynamic_contentare reserved, because they are Liquid roots. - The grid must be rectangular. Every row must be a non-empty object with exactly the same
column set as the first row; column order is pinned to the first row. Duplicate row keys are
rejected.
nullvalues become empty strings. - Size: 200 KB per table and 2 MB per app, both enforced as
413.
The CSV workflow
This is the practical loop for a marketing or localisation team: export, edit in a spreadsheet, re-upload.
Code
CSV shape:
Code
- The first column is the row key; the remaining columns are values. The downloaded header
starts with the literal word
key. - At least two header columns are required, header cells must be non-empty, and duplicate header names are rejected.
- A UTF-8 byte order mark is stripped, blank lines are skipped, short rows are padded with empty strings.
- Empty or duplicate row keys are rejected with the offending line number.
- The multipart field must be named exactly
file; otherwise you get400 multipart field 'file' is required.
The round trip is lossless: download, re-upload, and you have the same table.
Missing tables and missing rows
The two failure modes are deliberately different:
- A missing table fails loudly, at compose and preview time. If a message references
dynamic_content.offersand noofferstable exists, creating the message fails with an error naming the table. It does not resolve to empty and it does not fail per device at send time. - A missing row or column inside an existing table resolves to empty, because that is Liquid's lax-variable behaviour. There is no built-in fallback. Write one:
Code
When values are frozen
References are validated when you compose. The values are snapshotted when delivery begins. A message scheduled for Friday therefore picks up whatever the table says on Friday, not what it said when you wrote the message. If you need Friday's copy locked in today, edit the table after the send, not before.
Limits
| Limit | Value |
|---|---|
| Dynamic content table | 200 KB |
| Dynamic content per app | 2 MB |
| Table name | 63 characters, ^[a-z][a-z0-9_]{0,62}$ |
| Liquid loop iterations | 100 |
| Liquid local variables per render | 100 |
| Liquid output | 8192 bytes |
| Context depth / block nesting | 20 |
custom_data | 2 KB serialized |
Not available:
- No custom filters beyond
pluralize, and no way to register your own. - No
includeorrender— templates cannot compose other templates. - Liquid does not run inside arbitrary
datavalues — only the fields listed above, plus action button labels. - Liquid does not run in segment rules. Segments are a fixed filter vocabulary, not expressions.
- No per-recipient send-time data fetch. Everything Liquid can see is a tag, a device attribute, message custom data, or an uploaded dynamic content table.
- Dynamic content values are strings only — the grid is a two-level string map, not arbitrary JSON.
FAQ
Why did my message ship with {{ first_name }} visible in it?
Rendering failed for that field and emitted its raw source rather than dropping the send. Check
render_errors on the report and reproduce with render-preview. The usual cause is a typo in a
filter name — unknown filters are errors here.
Why is my personalized greeting empty for some people?
Missing variables render as empty. Add | default: "…" to every tag you are not certain exists on
every user.
Can I use a tag key with a space or a dot in it?
Through user.tags, yes: {{ user.tags['order status'] }}. Top-level flattening only helps for
keys that are valid identifiers.
How do I test what a specific user will see?
POST /v1/apps/{app_id}/render-preview with that user's tags, language and country. There is no
"preview as user X" shortcut that reads a live user for you.
Does dynamic content count toward the payload size? Only what it renders into. The 4 KB payload budget applies to the finished per-device payload, not to the table.
Why does my dynamic content preview look empty in the console's audience step? That step uses the audience preview, which does not resolve dynamic content. Use the render preview instead.
Related
- Sending messages — per-language content and custom data
- Templates — where Liquid is validated at save time
- Segments — targeting on the same tags
- A/B testing — Liquid inside variant copy
- Templates and dynamic content API reference