Notification payloads
Payload fields, custom data, localization, and platform overrides.
A notification is a payload and an audience, posted to POST /v1/messages. The payload is the
part your recipients see.
Anatomy
{
"audience": { "device_ids": ["01937b1e-0000-7000-8000-000000000000"] },
"payload": {
"title": "Your order has shipped",
"body": "Track it from the app.",
"data": { "order_id": "1234" },
"localizations": {
"fr": { "title": "Votre commande est partie", "body": "Suivez-la depuis l'application." }
}
}
}| Field | Shape | Notes |
|---|---|---|
title | 1 to 256 characters | Optional on its own |
body | 1 to 2048 characters | Optional on its own |
ttl | Integer from 0 to 2419200 seconds | Default 259200 (72 hours), starting at the first send attempt |
priority | high or normal | Default high; APNs 10/5 and FCM HIGH/NORMAL |
collapse_id | 1 to 64 UTF-8 bytes | APNs collapse header and FCM collapse key |
image | HTTPS URL, up to 2048 characters | Localizable; iOS requires a notification service extension |
subtitle | 1 to 256 characters | iOS alert subtitle; localizable |
thread_id | 1 to 64 characters | iOS notification grouping; included in the SDK stamp for Android foreground grouping |
sound | 1 to 64 characters | Native sound name; default when omitted. Android channels may override it |
badge | Nonnegative integer | Absolute badge count, not an increment |
android | Raw Android configuration object | Recursively merged over generated FCM Android options |
data | JSON object, including nested objects and arrays | Top-level string values up to 1024 characters; provider payload size limits still apply |
localizations | Map keyed by BCP 47 tag | Each entry overrides only the fields it carries |
apns | Raw APNs object | Recursively merged over generated fields |
At least one of title, body or a raw apns override must be present. A payload with none of the
three is refused with 422 empty_payload.
Campaign tags
Add an optional top-level tags array to classify a send:
{
"audience": { "external_ids": ["user-123"] },
"tags": ["new_comment", "social"],
"payload": { "title": "New comment", "body": "Someone replied to your post." }
}Use up to 10 strings of 1-64 characters. Matching is exact and case-sensitive;
duplicates are removed. Omitting tags stores an empty list. Tags belong to the
campaign, are shared by all its deliveries, and cannot be edited after creation.
They are returned by GET /v1/messages/{id} and appear in the dashboard's message
list and campaign detail. Use the Tag filter in Messages to find campaigns
with a particular tag, or add tags in the dashboard's notification composer.
We recommend a small set of stable categories such as like, new_comment and
chat_message. Avoid user IDs, post IDs, timestamps and other unique values.
Put per-notification context in payload.data instead.
Campaign tags are separate from device and user tags: they do not select an audience and are never included in the APNs or FCM payload. They support filtering message history and comparing aggregate delivery statistics.
Custom data, and the reserved key
data travels alongside the alert and is read by your app when the notification is opened. Keep it
small: send a resource id your app can fetch the rest from, rather than the content itself.
Values may be strings, numbers, booleans, nulls, objects or arrays:
{
"data": {
"route": "order",
"order": { "id": "42", "items": [{ "sku": "rod", "quantity": 2 }] },
"actions": ["view", "track"]
}
}APNs carries objects and arrays directly. For FCM, Carillon JSON-encodes them and marks
which fields were encoded. Starting with Kotlin and React Native SDK 0.3.0, the SDKs decode only those fields;
a string that looks like JSON stays a string. React Native exposes the decoded values in
onReceived's data and onOpened's payload. Kotlin exposes them in structuredData,
while data remains the original string map. Swift receives the original JSON values.
Older Android SDKs receive JSON strings and require manual decoding.
Existing scalar behavior is unchanged: Android receives numbers and booleans as strings, and null as an empty string. iOS preserves their JSON types. The final provider payload, including encoded data and tracking metadata, must fit within 4096 UTF-8 bytes.
`carillon` is reserved
Every payload carries a carillon key holding the delivery identifier that
open tracking needs. A payload that sets that key itself is refused
with 422 reserved_data_key rather than silently overwritten.
Localizations
A device reports its locale when it registers. At send time each device is matched against
localizations in three steps:
Its exact tag, so fr-CA finds a fr-CA entry.
Its language alone, so fr-CA falls back to a fr entry.
The base text, title and body, when neither matched.
An entry overrides only what it carries. A localizations.fr with a title and no body keeps the
base body.
Per-platform behaviour
iOS. title and body become the APNs alert. data becomes top-level keys of the payload
beside aps. Apple caps a notification at 4096 bytes, and a payload over that is refused before
anything is attempted.
Android. title and body become an FCM notification. data becomes the FCM data map, whose
values are strings on the wire: numbers and booleans are converted. Decode them in the app.
Both. Display is the operating system's decision and then your app's. Carillon does not set the operating system’s channel importance. Sound and badge fields are requests; OS settings and Android notification channels can override them. Foreground presentation requires the native SDK callbacks.
Sound. Every notification carries sound: "default" on both platforms unless the payload sets
sound. Name a sound bundled with the app to play it instead; on Android 8 and later the channel's
sound wins over either.
Lifetime and retries
ttl starts at the first send attempt, after scheduling and pacing. Its absolute
expiry is retained across retries. An expired delivery fails with
NotificationExpired without contacting the provider. ttl: 0 requests immediate
delivery without provider storage. An Android ttl override uses FCM's duration
format (for example "3600s") and also keeps a fixed deadline across retries.
NotificationExpired is not Abandoned. The first means the ttl you set ran out before an
attempt could be made. The second means a delivery stayed queued for 24 hours without ever being
handed to a provider, and maintenance wrote it off; it can be sent again. See
troubleshooting.
collapse_id lets a provider replace older pending notifications with the same
identifier. It is not business deduplication: use dedup_key when you need that.
Images and presentation
image adds aps.mutable-content = 1 and carillon.image on iOS, and
android.notification.image on Android. Apple requires a notification service
extension to download and attach the image. Without that extension, the text can
arrive without the image. Android background images are handled by FCM.
Formats and size limits differ by platform:
| Platform | Formats | Limit |
|---|---|---|
| iOS attachment | jpg, png, gif | The extension downloads up to 10 MiB. webp cannot be attached: the notification falls back to text |
| Android, rendered by the system | What FCM accepts, including jpg, png, webp | FCM downloads up to 1 MiB; a larger image is dropped and the text is shown |
| Android, app in the foreground | What the platform decodes | The SDK downloads up to 10 MiB |
Serve one image under 1 MiB in jpg or png to satisfy every path with a single URL.
{
"title": "Order shipped",
"image": "https://example.com/order.jpg",
"subtitle": "Delivery tomorrow",
"thread_id": "orders",
"badge": 1,
"sound": "default",
"localizations": { "fr": { "subtitle": "Livraison demain" } },
"android": { "notification": { "channel_id": "orders" } }
}Raw android options win conflicts within the generated Android configuration.
Carillon restores its reserved tracking stamp afterward, including in Android-specific
data. thread_id does not create an Android system notification group: FCM has
no corresponding notification field.
The API checks every localized APNs and FCM payload against 4096 UTF-8 bytes,
including the tracking stamp and raw overrides. Oversized payloads return
422 payload_too_large. Invalid image URLs return 422 invalid_image_url.
The raw APNs override
payload.apns is an escape hatch for anything Apple documents and Carillon does not model yet:
interruption-level, a critical alert or other advanced APNs options.
{
"payload": {
"title": "Gate change",
"body": "Boarding now at B22.",
"apns": {
"aps": {
"interruption-level": "time-sensitive",
"thread-id": "flight-AF1234"
}
}
}
}It is merged over the generated APNs payload. Objects are merged recursively: aps.alert.title overrides the generated title but preserves
a generated body when the override omits it. A string or other non-object aps.alert replaces
the generated alert entirely. The reserved carillon delivery identifier is added afterwards.
Related pages
Devices · Delivery · Send a notification
List tags already used
Use a secret key with deliveries:read to discover existing notification labels:
curl https://api.carillon.dev/v1/messages/tags \
-H "Authorization: Bearer $CARILLON_SECRET_KEY"{ "tags": ["like", "new_comment"], "next_cursor": null }Results contain distinct tags from stored campaigns in the key's app and mode: test keys see test campaigns, live keys see live campaigns. Each page contains up to 100 labels sorted by label. When next_cursor is not null, URL-encode it and pass it as cursor to retrieve the next page. An app with no tags returns an empty tags array and a null cursor.
Agents can use notification_tags_list to discover the same labels before sending.
Compare tag statistics
In the dashboard, open Overview → Statistics and choose the last 7, 30 or 90 days. The table covers live campaigns created in that period and follows the source filter inside that block. Click a tag to open the filtered message list. In Messages, use the Tags multi-select to match any of several labels. Without tag selects untagged campaigns.
Each row shows campaigns, recipients, sent, failed, pending and opened deliveries,
plus the open rate (opened / sent). Recipients count deliveries, not unique users
across campaigns. Counts use retained delivery history. A campaign with multiple
tags contributes to each tag, so do not add the rows together as a global total.
The All row and the KPI totals count each campaign once, including untagged campaigns.
Completed campaigns with no recipients are hidden from the message list by default. Enable Include messages with no recipients to see them. Scheduled and in-progress campaigns remain visible, and direct campaign links still work.