Appearance
Transactional sends & Live Activities
Transactional lane
POST /v1/apps/:appId/notifications publishes straight to a dedicated high-priority queue. Transactional sends:
- are never queued behind campaign fan-outs (a 100k blast cannot delay an OTP — this is tested, not aspirational);
- are exempt from frequency caps, quiet hours and the subscriber's channel preference — the
channelsyou pass is the only channel filter; - cost nothing extra — pricing is per active user, not per message.
bash
curl -X POST https://api.wingblaze.net/v1/apps/$APP_ID/notifications \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"subscriberId": "...", "title": "Your code", "body": "123456"}'Templates and variables
Saved copy renders when your backend sends — with the values you pass, the person's tags, and their language:
bash
POST /v1/apps/:appId/notifications
{
"templateName": "order_shipped",
"externalUserId": "u_42",
"variables": { "order_id": "A-1042", "eta": "Thursday" }
}A template such as Hi {{ tags.first_name | default: "there" }}, order {{ order_id }} arrives {{ eta }} becomes final text before it is queued: variables are yours (flat scalars, up to 50; {{ order_id }}), tags are the subscriber's ({{ tags.plan }}), and external_user_id / subscriber_id are theirs too. A template with per-language copy picks the variant by the subscriber's language tag, region → base language → default, exactly as campaigns do. Inline title / body render the same way when they contain {{ … }}, and a field you pass alongside a template stays yours as written (only the template's own fields take its per-language variant); data string values render as well ("deepLink": "app://orders/{{ order_id }}", and may render empty), and the rendered payload is re-checked against the delivery-option rules and its buttons normalized, so a variable cannot turn a button into a javascript: link.
Rendering is strict on this lane, deliberately: a reference that does not resolve, a filter that does not exist, or a title or body that renders to nothing is a 400 naming the field (body: undefined variable: order_id) — never "Your code is " delivered to a customer. Values are strings, numbers or booleans (a null would render empty, so it is refused up front). Use {{ tags.x | default: "…" }} — default as the first filter — or {% if tags.x %} for values that may be missing; an {% assign %} from a missing value is not caught. Copy that must contain literal braces goes inside {% raw %}…{% endraw %}. A runaway template (a nested loop, a value doubling itself) is cut off by the engine's own limits and refused the same way. A refused call hands its idempotency key back, so the corrected call sends instead of replaying a 400. The worker renders nothing on this lane: what is queued is what goes out, and a template edited later never changes a send already made.
Batch sends
One call, many recipients, one job each — "your order shipped" to three hundred people without three hundred requests:
bash
POST /v1/apps/:appId/notifications/batch
{
"templateName": "order_shipped",
"channels": ["fcm", "apns"],
"idempotencyKey": "shipments-2026-09-07-batch-17",
"recipients": [
{ "externalUserId": "u_42" },
{ "externalUserId": "u_43", "body": "Two parcels this time.", "data": { "orderId": 9931 } },
{ "subscriberId": "0f3c…" }
]
}Content is shared — inline title / body / data / variables or a template — and any recipient may override title, body or data (data replaces wholesale) and add its own variables (merged over the batch's). Up to 1,000 recipients per call, inside the 1 MiB request limit (about 1 KB per recipient at the cap, data included). The answer is a 202 with queued, replayed and rejected counts and a per-recipient results list: an unknown recipient is not_found and does not stop the others, so always read results.
A batch idempotencyKey gives each recipient the key <key>#<subscriberId> (a recipient may name its own, which means the same thing on the single call), so a retried batch replays every recipient's original job whatever order it lists them in, and a changed recipient is a conflict for that recipient. A person listed twice in one keyed batch gets one send (the second entry reports in_flight); give the entries their own keys — an order id, say — when both are wanted. A recipient whose publish failed is failed with its key released: retry the keyed batch and only that one sends. Without a key a batch cannot be replayed, so retry only the recipients reported failed. The template is resolved once and rendered per recipient (their variables, tags and language); a recipient whose copy cannot be rendered is invalid with the field and reason, its key released, and does not stop the others. Same lane, same rules: no policy, final copy at accept time, channels is the only channel filter — and the same quota: a batch costs one unit of your org's transactional rate limit per recipient, exactly as if you had sent them one by one. A batch that does not fit is refused whole with a 429 before anything is queued, and its recipients are not charged — only the request itself, like any other.
Delivery status
The id a send returns is on every receipt the worker records for it, so your backend can ask where it stands without keeping a webhook:
GET /v1/apps/:appId/notifications/:idstatus adds the receipts up across the person's devices, the latest receipt per device standing — delivered if any device got it, else bounced, else failed (every retry exhausted and nothing landed since; an operator redrive that lands it flips this to delivered), else suppressed, else pending. SMS and WhatsApp report delivered when the provider accepts the message; a late undelivered / failed from the Twilio status callback or Meta's webhook is attributed back to the send and flips it to bounced. Readable with the send scope as well as read. outcomes lists each receipt per device, oldest first, with the channel, the reason (a suppression's, a bounce's provider reason, a failure's) and the provider's message id where there is one; failures counts open and recovered dead letters. A send usually settles within seconds, so pending means poll again; an id this app never queued stays pending, and receipts age out with the app's events retention. Batch sends expose the same per-recipient ids in their results.
iOS Live Activities
Drive lock-screen / Dynamic Island widgets from your backend. Updates also ride the transactional lane — stale live data is worthless.
1. App side — start the activity and attach it to WingBlaze; the SDK registers the push token and keeps it fresh through rotation:
swift
let activity = try Activity.request(attributes: attrs,
content: .init(state: initial, staleDate: nil), pushType: .token)
WingBlaze.attachLiveActivity(activity) { serverId in
// send serverId to your backend
}2. Backend side — update and end by that id:
bash
POST /v1/apps/:appId/live-activities/:id/update
{ "contentState": { "eta": "3 min" }, "alert": { "title": "Almost there", "body": "3 min away" } }
POST /v1/apps/:appId/live-activities/:id/end
{ "contentState": { "eta": "arrived" } }Content state is limited to 4KB (APNs). Dead activity tokens and end events close the registry row automatically. Requires your app's Live Activity widget and push capability from your Apple developer setup.