# 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](sending-messages.md) 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.

```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 '{
        "title": "{{ nm }}, your streak is at {{ streak_days }}",
        "body": "{% if tier == \"gold\" %}Your gold bonus is waiting.{% else %}Two more days unlocks gold.{% endif %}"
      }'
```

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.language` is normalised: lowercased, underscores turned to hyphens, first subtag only, with
  the obsolete Java codes remapped (`iw` → `he`, `in` → `id`, `ji` → `yi`). `subscription.language`
  is the raw value the device reported.
- `user.tags` is the whole map, so `{{ user.tags['order status'] }}` works for keys with spaces
  that cannot be written as a bare identifier.
- `message.custom_data` is whatever you passed in the send's `custom_data` object — 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 }}` renders `user_8412`
- `{{ nm | default: "Runner" }}` renders `Runner`

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:

```liquid
Welcome back, {{ first_name | default: "friend" }}!
Welcome back, {{ first_name|friend }}!
```

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:

```liquid
You have {{ n }} {{ n | pluralize: "message" }}.
{{ n }} {{ n | pluralize: "entry", "entries" }}
```

`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 `data` stay 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 `400` naming 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`:

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

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.

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/render-preview \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "{{ nm }}, {{ streak_days }} days and counting",
        "body": "{{ dynamic_content.offers[tier].headline }}",
        "tags": {"nm": "Ines", "streak_days": "12", "tier": "returning"},
        "external_id": "user_8412",
        "language": "pt-BR",
        "country": "BR"
      }'
```

```json
{
  "rendered": {
    "title": "Ines, 12 days and counting",
    "body": "Welcome back — your usual, on us",
    "image_url": "",
    "deep_link": "",
    "subtitle": ""
  },
  "errors": [],
  "dynamic_content": ["offers"]
}
```

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`, not `audience-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-preview` is 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}}`.

```bash
curl -X PUT https://app.openpush.ai/v1/apps/acme-app/dynamic-content/offers \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "data": {
          "starter":   {"headline": "Start strong — 20% off week one", "cta": "Claim it"},
          "returning": {"headline": "Welcome back — your usual, on us", "cta": "Order again"}
        }
      }'
```

Reference it either way round — the transpose is materialised for you:

```liquid
{{ dynamic_content.offers[tier].headline }}
{{ dynamic_content.offers.headline[tier] }}
```

| 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 names `user`, `subscription`, `message`, `app` and
  `dynamic_content` are 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. `null` values 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.

```bash
# Download the current table
curl https://app.openpush.ai/v1/apps/acme-app/dynamic-content/offers/csv \
  -H "X-OP-API-Key: $OP_REST_KEY" -o offers.csv

# Upload an edited one
curl -X POST https://app.openpush.ai/v1/apps/acme-app/dynamic-content/offers/csv \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -F "file=@offers.csv"
```

CSV shape:

```csv
key,headline,cta
starter,Start strong — 20% off week one,Claim it
returning,"Welcome back — your usual, on us",Order again
```

- **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 get
  `400 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.offers` and no `offers` table 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:

```liquid
{{ dynamic_content.offers[tier].headline | default: "Something new is waiting" }}
```

### 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 `include` or `render`** — templates cannot compose other templates.
- **Liquid does not run inside arbitrary `data` values** — 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](sending-messages.md) — per-language content and custom data
- [Templates](templates.md) — where Liquid is validated at save time
- [Segments](segments.md) — targeting on the same tags
- [A/B testing](ab-testing.md) — Liquid inside variant copy
- [Templates and dynamic content API reference](../api-handbook/05-templates-dynamic-content.md)
