Architecture
Notification policy on your backend Free
The on-device policy runs only for foreground messages. For background and terminated apps, the OS shows the message before any Dart code runs. So your server must apply the same rules before it calls the push vendor.
Archipelago ships no server code for this. This page is the contract, with test cases. You write the engine in your own language, in about 200 lines. This page shows how to connect it to the generated app.
How the pieces fit
- The app sends its preferences and push token to your backend.
- The app sends an open receipt to your backend when the user taps a push.
- Your backend calls
decide(message, prefs, history, holidays, now)for each outgoing push. - Your queue acts on the result:
send,defer,digestordrop.
decide is a pure function. It reads its inputs and returns one decision. It stores nothing and calls no vendor. Your queue does that work.
Step 1: Sync the preferences snapshot
Send the snapshot with the push token. Send it again when the user changes a setting.
final sdk = getIt<PushNotificationSDK>();
final token = await sdk.getToken();
final prefs = await sdk.notificationPreferences();
await api.post('/me/notification-preferences', body: {
'token': token?.token,
'provider': token?.provider.name,
...prefs.toJson(),
});
// {"v": 1, "tz": "Asia/Jakarta",
// "quiet_hours": {"start": "22:00", "end": "07:00"}, "marketing": true}The snapshot holds four keys:
vis the record version,1.tzis the IANA time zone of the device.quiet_hoursisnullor a window ofHH:MMwall-clock times intz.marketingis the marketing consent.
Your backend adds two keys from its own data: digest (enabled and at) and region. Store the full record per user or install.
Step 2: Send the split payload
Every message the engine sends carries data.nid and, for tagged messages, data.tier. The nid is a message ID that is unique per user.
A sensitive message has sensitive set to "true", or a critical or transactional tier. For it, the visible notification block holds the Safe text. The Detail text goes in data.detail_title and data.detail_body.
The app shows the Detail text in the app. The lock screen shows the Safe text. All data values are strings.
Send the detail keys only on a sensitive message. The app ignores detail_title and detail_body on a message that is not sensitive: it shows the visible title and body, as before the split existed.
FCM
{
"message": {
"token": "<fcm-registration-token>",
"notification": { "title": "New message", "body": "Open the app to read it." },
"data": {
"nid": "m-123",
"tier": "transactional",
"sensitive": "true",
"detail_title": "Payment received",
"detail_body": "You received 150,000 from Sari."
}
}
}APNs
Send through FCM and use the same payload as above. If you call APNs directly, put the custom keys next to aps.
{
"aps": { "alert": { "title": "New message", "body": "Open the app to read it." } },
"nid": "m-123",
"tier": "transactional",
"sensitive": "true",
"detail_title": "Payment received",
"detail_body": "You received 150,000 from Sari."
}OneSignal
Put the keys in data. OneSignal shows them to the app as Additional Data.
{
"app_id": "<onesignal-app-id>",
"include_subscription_ids": ["<subscription-id>"],
"headings": { "en": "New message" },
"contents": { "en": "Open the app to read it." },
"data": {
"nid": "m-123",
"tier": "transactional",
"sensitive": "true",
"detail_title": "Payment received",
"detail_body": "You received 150,000 from Sari."
}
}A message that is not sensitive has no sensitive or detail_* keys. Its visible block holds the full text. Keep other keys, such as route, in data as before.
Step 3: Forward opens for back-off
Back-off needs to know which pushes the user ignored. Forward each tap with its nid.
getIt<PushNotificationSDK>().tapStream.listen((notification) {
final nid = notification.data['nid'];
if (nid == null) return;
api.post('/me/notification-opens', body: {
'nid': nid,
'opened_at': DateTime.now().toUtc().toIso8601String(),
});
});On your backend, set opened_at on the matching history entry. Any open, of any tier, resets the back-off count.
Without these receipts, back-off pauses users who engage with your app in other ways. Add the listener before you turn back-off on.
Step 4: Run digest and deferral in your queue
The engine decides. Your queue acts. Two decisions need a timer.
| Decision | Your queue does this |
|---|---|
send | Call the vendor now. Record the send in history. |
defer | Hold the message until until. Then call decide again and act on that result. |
digest | Add the message to the user's batch due at until. |
drop | Do not send. reason is no_consent, holiday, back_off or cap. |
Always call decide again at until. A consent revoke, a pause or a full cap during the night still applies then.
on_due(message_id):
m = load(message_id)
result = decide(m.message, load_prefs(m.user), load_history(m.user), holidays, now())
act(result) // send, defer again, digest or dropA deferred marketing message with digest on is deferred first. When decide runs again at the end of the quiet window, it returns digest.
Your queue builds and sends the digest batch. Treat the batch as one message. Give it its own nid and record it in history like any other send.
Step 5: Supply holiday data
The engine reads a map from region to local dates. Archipelago ships no holiday data.
{ "ID": ["2026-08-17"] }Use these public sources:
- The official calendar of the government for each region you serve.
- A national gazette or a joint ministerial decree that lists public holidays.
- An open-source holiday library that you pin to a version and review each year.
Check the date in the user's local time, using tz from the snapshot. Never use UTC. Refresh the data every year. Floating holidays, such as those set by a moon sighting, change late, so re-import them when the government announces them.
For zone and DST rules, use the IANA time zone database. Most languages ship it in their standard library or a well-known package.
Step 6: Fetch highly sensitive detail by nid
detail_title and detail_body still pass through the push vendor. For an OTP, a balance or a medical result, leave the detail out.
- Send only the Safe text and
data.nid. - The app reads
nidfrom the message. - The app fetches the detail from your API, over your own auth.
final nid = notification.data['nid'];
final detail = await api.get('/me/notifications/$nid');Your API checks that the signed-in user owns the nid. Expire the detail after a short time.
Step 7: Run the vectors in your test suite
The test vectors hold 41 cases. An engine conforms when it passes all of them. Run them in CI.
Each case has an id, an input and an expected result. The pseudo-code below works in any language.
cases = parse_json(read("vectors.json")).cases
for c in cases:
i = c.input
actual = decide(i.message, i.prefs, i.history, i.holidays, parse_time(i.now))
assert actual.decision == c.expected.decision
if c.expected.decision == "send":
assert deep_equal(actual.payload, c.expected.payload)
if c.expected.decision == "drop":
assert actual.reason == c.expected.reason
if c.expected.decision in ["defer", "digest"]:
// compare instants, not strings
assert parse_time(actual.until) == parse_time(c.expected.until)Follow these rules:
- Compare
untilas an instant. The vectors write it in the user's local offset, and your engine can return UTC. - Treat
prefs: nullas no record. That means no consent. - Treat a missing
holidayskey as an empty map. - Pass
nowintodecide. Never read the system clock inside it.
The vectors cover the hard cases: quiet hours across midnight, a DST gap and overlap, a holiday in local time against UTC, the back-off ladder and its reset, cap rollover and untagged pass-through.
Defaults
| Rule | Default |
|---|---|
| Marketing cap | 3 sends in any rolling 24 hours, the same as the device. |
| Ignored push | A marketing send with no open, 48 hours after it was sent. |
| Back-off | 3 ignored sends in a row pause marketing for 7 days. Each further ignored send doubles the pause, up to 28 days. |
| Check order | Consent, holiday, back-off, cap, quiet hours, digest. The first failing check decides. |
Untagged messages have no tier, or a tier that is not an exact match. The engine sends them as is. It never caps, delays or splits them. Tag every message that carries amounts or personal data.