Notification guide
What you can put in a notification, how browsers render it, and the patterns that work for campaigns. Everything
here maps directly onto the validation in src/utils/validation.js.
Links, images and buttons
A notification can carry more than text. Every field below is validated, encrypted and delivered, and the exact
payload is echoed back on GET /v1/notifications/:id under payload so you can audit what
was sent.
| Field | Renders as |
|---|---|
url | Where a click on the notification body navigates |
icon | The small icon beside the title (192×192 recommended) |
image | A large image inside the notification (Chrome, Edge, and some Android browsers show it) |
badge | The monochrome icon Android shows in the status bar (72×72) |
tag | Replaces a visible notification with the same tag instead of stacking |
actions | Up to 3 buttons, each with a title and its own url; most mobile platforms show 2 |
data | Arbitrary JSON delivered to the Service Worker, never rendered |
ttl | How long the push service queues the message for an offline device |
urgency | A delivery hint: very-low, low, normal, high |
Clicks stay on your website
The Service Worker only opens URLs on the origin it is served from. An absolute link to another domain is stored and delivered, but a click will focus the site instead of leaving it — that is deliberate, so a leaked key cannot be used to bounce subscribers to an arbitrary destination.
The payload
{
"title": "Special Discount!",
"body": "Get 10% OFF your purchase for the next 10 hours.",
"url": "https://example.com/offers",
"icon": "https://example.com/icons/offer-192.png",
"badge": "https://example.com/icons/badge-72.png",
"image": "https://example.com/images/offer.jpg",
"tag": "special-discount",
"data": { "campaign": "discount-2026" },
"actions": [
{ "action": "open", "title": "View offer" },
{ "action": "dismiss", "title": "Not now" }
],
"ttl": 3600,
"urgency": "high"
}
| Field | Limit | Notes |
|---|---|---|
title | 1–160 chars, no newlines | Required. Long titles are truncated by the OS, not by us. |
body | ≤ 1200 chars | Newlines allowed; most platforms show 2–4 lines. |
url | http(s), ≤ 2048 | Relative values resolve against your registered origin. Opened on click. |
icon | http(s) URL | Square image; 192×192 is a safe size. |
badge | http(s) URL | Monochrome small icon (Android). |
image | http(s) URL | Large preview image; Chrome Android only, ignored elsewhere. |
tag | ≤ 64 chars | Notifications sharing a tag replace each other instead of stacking. |
data | 4 levels, 50 keys, 100 array items | Opaque to Creator Notify; delivered to the Service Worker. |
actions | ≤ 3 (browsers show ≤ 2) | { action, title, icon? }. |
ttl | 0–2419200 s | How long the push service may retain an undelivered message. |
urgency | very-low, low, normal, high | A hint for battery/Doze handling (RFC 8030). |
Empty fields are stripped before delivery, so a payload without body costs less against
MAX_PUSH_PAYLOAD_BYTES (3500 bytes by default). Data URLs, javascript: and other
schemes are rejected for every URL field.
Title and message
- Lead with the fact. "Order #1234 shipped" beats "You have an update".
- Front-load. Android collapses to roughly 40 characters per line.
- No markup. HTML is rendered literally; emoji work but tone depends on the OS font.
- No control characters. The API rejects them with
INVALID_REQUEST.
Icons and images
{
"title": "New episode",
"body": "Episode 12: Shipping on a Friday",
"icon": "https://example.com/icons/podcast-192.png",
"image": "https://example.com/covers/episode-12.jpg",
"url": "https://example.com/podcast/12"
}
- Use absolute HTTPS URLs from a domain you control; some browsers block mixed content.
imageis ignored on platforms that do not support it — the notification still appears.- The Service Worker drops any image URL outside the allowed origins rather than fetching it.
Click destination
url may be absolute or relative. Relative paths are resolved against your registered origin, which
is also the only origin the Service Worker will navigate to:
{ "title": "Dashboard", "body": "Your weekly report is ready", "url": "/reports/weekly" }
// → opens https://YOUR-REGISTERED-ORIGIN/reports/weekly
To send users to a different domain, do it from your own page after the click — do not fight the allow-list.
Tags and replacement
A notification with a tag replaces any visible notification with the same tag on that device. This
keeps streams tidy:
| Scenario | Tag | Result |
|---|---|---|
| Live score updates | match-8812 | One notification that updates in place |
| Daily digest | digest | Yesterday's digest is replaced by today's |
| Independent alerts | omit | Each notification stacks separately |
Tags are per device, not per campaign
Two campaigns that reuse "promo" will overwrite each other. Namespace tags by feature:
orders-shipped, promo-spring-2026.
Campaign examples
Transactional update
{
"title": "Your order has shipped",
"body": "Tracking number 1Z999AA10123456784.",
"url": "https://example.com/orders/1234",
"tag": "order-1234",
"data": { "orderId": 1234, "event": "shipped" }
}
Time-boxed promotion
{
"title": "10% off ends tonight",
"body": "Use code SPRING10 before midnight.",
"url": "https://example.com/offers",
"tag": "promo-spring-2026",
"ttl": 21600,
"urgency": "normal"
}
ttl: 21600 (6 hours) tells the push service to discard the message if the device is offline past the
offer window — better than showing a dead promotion tomorrow.
Breaking news with actions
{
"title": "Service disruption resolved",
"body": "All regions are back to normal since 09:20 UTC.",
"url": "https://status.example.com/incidents/42",
"tag": "status-incident-42",
"actions": [
{ "action": "details", "title": "Read update" },
{ "action": "dismiss", "title": "Dismiss" }
],
"urgency": "high"
}
Bulk publishing
One publish request addresses every active subscription of the authenticated site. There is no per-audience targeting and no segmentation API — sending to a subset means maintaining separate registrations (one per audience), which is exactly what the multi-tenant design supports.
| Audience size | What happens | Response |
|---|---|---|
| 0 | Nothing is attempted | 200, warnings: ["NO_ACTIVE_SUBSCRIPTIONS"] |
≤ SYNC_DELIVERY_LIMIT (50) | Delivered inline with bounded concurrency | 200, status: "completed" |
| > limit, queue bound | A bounded slice is attempted inline, the rest is finished in the background | 202, status: "queued" |
| > limit, no queue | A bounded slice is attempted inline, the cron sweep finishes it | 202, status: "processing", warning ASYNC_DELIVERY_WITHOUT_QUEUE |
Poll for final numbers:
curl -s https://YOUR-API-HOST/v1/notifications/ntf_7hq2... \
-H "Authorization: Bearer $CREATOR_NOTIFY_KEY"
Pacing repeated sends
# shell: send 3 campaign messages 30s apart, fail fast on errors
for title in "Offer 1" "Offer 2" "Offer 3"; do
curl -sS -X POST "$CREATOR_NOTIFY_URL/v1/notifications" \
-H "Authorization: Bearer $CREATOR_NOTIFY_KEY" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"$title\",\"body\":\"Limited stock\",\"tag\":\"${title// /-}\"}" \
--fail-with-body || exit 1
sleep 30
done
Reading the delivery report
| Field | Meaning |
|---|---|
accepted_subscriptions | Active subscriptions targeted when the request was persisted. |
successful_submissions | Push service returned 2xx for that endpoint. |
failed_submissions | Push service returned an error (4xx/5xx) or the request failed/timed out. Retryable in principle; the subscription is kept. |
invalid_subscriptions | The endpoint returned 404/410 and was marked invalid. It will never be contacted again. |
pending_submissions | Not attempted yet — delivery continues asynchronously. |
These are push-service acknowledgements. They are not impressions, and Creator Notify will not report them as
such. For engagement, put a click URL with your own campaign parameters in url and measure on your site.