Creator Notify v1.0.0

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.

FieldRenders as
urlWhere a click on the notification body navigates
iconThe small icon beside the title (192×192 recommended)
imageA large image inside the notification (Chrome, Edge, and some Android browsers show it)
badgeThe monochrome icon Android shows in the status bar (72×72)
tagReplaces a visible notification with the same tag instead of stacking
actionsUp to 3 buttons, each with a title and its own url; most mobile platforms show 2
dataArbitrary JSON delivered to the Service Worker, never rendered
ttlHow long the push service queues the message for an offline device
urgencyA 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"
}
FieldLimitNotes
title1–160 chars, no newlinesRequired. Long titles are truncated by the OS, not by us.
body≤ 1200 charsNewlines allowed; most platforms show 2–4 lines.
urlhttp(s), ≤ 2048Relative values resolve against your registered origin. Opened on click.
iconhttp(s) URLSquare image; 192×192 is a safe size.
badgehttp(s) URLMonochrome small icon (Android).
imagehttp(s) URLLarge preview image; Chrome Android only, ignored elsewhere.
tag≤ 64 charsNotifications sharing a tag replace each other instead of stacking.
data4 levels, 50 keys, 100 array itemsOpaque to Creator Notify; delivered to the Service Worker.
actions≤ 3 (browsers show ≤ 2){ action, title, icon? }.
ttl0–2419200 sHow long the push service may retain an undelivered message.
urgencyvery-low, low, normal, highA 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.
  • image is 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:

ScenarioTagResult
Live score updatesmatch-8812One notification that updates in place
Daily digestdigestYesterday's digest is replaced by today's
Independent alertsomitEach 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 sizeWhat happensResponse
0Nothing is attempted200, warnings: ["NO_ACTIVE_SUBSCRIPTIONS"]
≤ SYNC_DELIVERY_LIMIT (50)Delivered inline with bounded concurrency200, status: "completed"
> limit, queue boundA bounded slice is attempted inline, the rest is finished in the background202, status: "queued"
> limit, no queueA bounded slice is attempted inline, the cron sweep finishes it202, 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

FieldMeaning
accepted_subscriptionsActive subscriptions targeted when the request was persisted.
successful_submissionsPush service returned 2xx for that endpoint.
failed_submissionsPush service returned an error (4xx/5xx) or the request failed/timed out. Retryable in principle; the subscription is kept.
invalid_subscriptionsThe endpoint returned 404/410 and was marked invalid. It will never be contacted again.
pending_submissionsNot 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.