Appearance
Concepts
Subscribers, devices, tags
A subscriber is a person. They own devices — one per channel endpoint (an FCM or APNs token, a web push subscription, an email address, an E.164 number for SMS/voice/WhatsApp/RCS/KakaoTalk, or a chat/user id for Telegram, LINE, Zalo and Viber) — and tags: unlimited key/value data (plan: pro, language: ms). Calling WingBlaze.login("user-123") binds the subscriber to your user id; multi-device users converge onto one subscriber.
Two tags have built-in behavior:
language(orlocale) selects multi-language message variants at delivery.- The subscriber's IANA
timezone(captured by the SDKs) powers timezone delivery, quiet hours, and optimal-time.
Channel preferences
A subscriber can limit which channels marketing uses for them:
json
PATCH /v1/apps/:appId/subscribers/:id
{ "preferredChannels": ["email", "webpush"] }null clears it. The dashboard's subscriber page has the same control, and the hosted preference centre (the "Manage preferences" link in every marketing email, next to the topic choices) offers it to the person themselves whenever they are reachable on two or more channels. That page shows the channels we can currently reach them on and writes only what changed: a save that only touched topics leaves the preference alone. The field rides along on every subscriber read, in both exports, and in the bulk import (omitted = keep), so moving an audience between apps keeps everyone's choice.
Campaign fan-out — the primary pass and any cross-channel fallback — and journey sends only touch devices on the preferred channels; with no preference, every enabled device is fair game (subject to the campaign's or step's own channels). A send held back by quiet hours re-checks the preference when it is released, so a choice made in the evening is honoured in the morning. The transactional lane ignores the preference for the same reason it is exempt from caps and quiet hours: an OTP goes where your call said. Segment conditions such as { "channel": "email" } stay about reachability (an enabled device); the preference is applied at send time.
An empty list is refused. To stop every marketing message for someone, unsubscribe them (the suppression list) — that is audited and per-address, and two mechanisms for one decision is how one gets missed.
Segments
Saved filters with nesting — all (every condition) and any (at least one) groups, three levels deep:
json
{
"all": [
{ "tag": "plan", "eq": "pro" },
{ "event": "purchase", "withinDays": 30, "atLeast": 2 },
{ "event": "opened", "notWithinDays": 14 },
{ "channel": "email" },
{ "any": [{ "tag": "country", "in": ["MY", "SG"] }, { "inactiveForDays": 60 }] }
]
}Four kinds of condition:
| Condition | Shape | Matches subscribers who… |
|---|---|---|
| Tag | { "tag": "plan", "eq" | "ne" | "exists" | "in": … } | carry the tag value (ne and exists: false also match people without the tag) |
| Event history | { "event": "purchase", "withinDays": 30 }, add "atLeast": 3 for a minimum count | tracked the event in the last N days — custom events or system ones (opened, clicked, delivered, bounced) |
{ "event": "purchase", "notWithinDays": 30 } | have no such event in the last N days (never having done it counts) | |
| Activity | { "activeWithinDays": 7 } | did something in the last N days: any custom event, an open/click/dismiss, an in-app or inbox beacon, or an SDK login. Messages you sent them (sent, delivered, bounced, journey lifecycle rows) are not activity |
{ "inactiveForDays": 30 } | did none of that for N days and have existed at least that long (a fresh signup is not "inactive for 30 days") | |
| Channel | { "channel": "email" } / { "channel": "apns", "exists": false } | are reachable (or not) on the channel — an enabled device; an unsubscribed or dead token does not count: fcm, apns, webpush, email, sms, voice, whatsapp, telegram, rcs, line, zalo, viber, kakao |
A condition is exactly one kind: { "tag": …, "event": … } in one object is rejected rather than half-applied. Windows are whole days, 1–90 — events are kept 90 days, so a longer look-back could not be answered honestly. An app that chose a shorter events retention in Settings is held to it: a segment or journey-condition window past its retention is refused with a 400 rather than evaluated against rows that are already gone, and the retention cannot be shortened below a window that is already saved. Segments are evaluated live, by one compiler, everywhere a filter appears: campaign audiences at send time, journey entry triggers and branch conditions, in-app targeting, feature-flag rollouts, and the preview count in the dashboard. The Segments page builder offers every condition as a row ("Did event … in the last … days", "Inactive for … days", "Has no channel …"), and "Describe your audience" can produce them from plain language.
Cross-channel fallback
A campaign can retry on other channels for the people who ignored it:
json
{ "channels": ["fcm", "apns"], "fallbackChannels": ["email"], "fallbackAfterMinutes": 120 }Two hours after the primary pass finishes, everyone targeted who has not opened or clicked the campaign gets the same message on the fallback channels. That deliberately includes people the primary never reached at all — no device on those channels, or a dead token. Engagement is counted per subscriber, so opening the push on a phone cancels the email.
The fallback channels must be explicit and must not overlap the primary channels (falling back onto a channel the primary just used would re-send to the same devices). The wait is 5 minutes to 7 days. While waiting, the campaign stays sending and can be paused or stopped like any other multi-hour send; the fallback send obeys frequency caps, quiet hours, the suppression list and each subscriber's channel preference exactly like the primary, and costs one more send per person it reaches.
Engagement is scoped to the occurrence, so a recurring campaign falls back again next time even to someone who opened the last one. On a recurring campaign the wait must fit inside half the period, because the campaign stays sending while it waits and an occurrence that comes due meanwhile would be skipped rather than delayed. A fallback cannot yet be combined with timezone or optimal-time delivery: the fallback pass is a single enqueue when the wait expires, so it has no per-subscriber clock to honour. All three are refused at creation rather than silently degraded.
If part of the primary is still parked by quiet hours when the wait expires, the fallback waits for it — telling someone they ignored a message that has not arrived yet, and then delivering it afterwards, is worse than being late.
Message templates
Copy you reuse, saved under a handle your code can hardcode:
json
POST /v1/apps/:appId/message-templates
{ "name": "order_shipped", "title": "On its way", "body": "Hi {{ tags.first_name }}, your order shipped." }A template is an authoring artifact, not a send. Starting a campaign or a transactional send from one copies its content, so editing a template later never rewrites what has already gone out and never mutates a message a campaign still points at. Deleting one is safe for the same reason.
Use it from your backend without repeating copy:
json
POST /v1/apps/:appId/notifications
{ "templateName": "order_shipped", "externalUserId": "u_42" }Any field you pass alongside the template overrides that field (payload and localizations replace wholesale rather than merging). POST /messages takes templateName or templateId the same way, which is how a campaign starts from one. The dashboard's Templates page manages them; the composer's "Start from a template" copies the whole template — copy, media, buttons, delivery options and translations — and its "Save as template" stores the whole message you composed under a handle the same way (audience and schedule are campaign settings, so they stay out). Saving under a handle that exists replaces that template only when you tick "Replace it", which is how you edit one from the dashboard; a replace keeps the fields the composer has no control for (video, audio, WhatsApp header parameters) and a button's id when its label is unchanged. The Templates page shows what each template carries. Handles are unique per app and may use letters, digits, _, . and -; a clash is a 409 rather than a silent overwrite. Template payloads are validated against the same contract messages use, so anything storable is sendable. Naming a template requires the read scope in addition to write/send, because dereferencing one returns its content.
The transactional lane renders at accept time. It is exempt from the marketing policy by design — no frequency caps, no quiet hours — and its worker does no per-recipient work, so POST /notifications resolves everything before it queues: {{ … }} references from the variables you pass, the subscriber's tags, external_user_id and subscriber_id, and a template's per-language copy by the subscriber's language tag. What is queued is final text. Rendering is strict there: a reference that does not resolve is a 400 naming the field, never an empty OTP (see Transactional). Deleting or renaming a template is safe for everything already sent (messages copied it), but a backend still posting that handle starts getting 404s, so handle changes are recorded in the audit log.
Messages and campaigns
A message is reusable content: title, body, image, deep link, per-language variants, and Liquid personalization from tags (see Campaigns for the syntax). A campaign targets a message at a segment with a schedule. See Campaigns for recurrence, timezone, optimal-time, throttling and A/B options.
Events
Anything you trackEvent lands in the append-only event stream, alongside automatic delivered / opened / clicked receipts. Events drive journey triggers, conversion goals, insights, and the MAU meter that generates your invoice.
Multi-tenancy
Everything is scoped to an org (billing boundary, team, API keys) and its apps (audience boundary). Agencies: see white-label & client workspaces.