Skip to content

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 ​

  1. The app sends its preferences and push token to your backend.
  2. The app sends an open receipt to your backend when the user taps a push.
  3. Your backend calls decide(message, prefs, history, holidays, now) for each outgoing push.
  4. Your queue acts on the result: send, defer, digest or drop.

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.

dart
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:

  • v is the record version, 1.
  • tz is the IANA time zone of the device.
  • quiet_hours is null or a window of HH:MM wall-clock times in tz.
  • marketing is 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 ​

json
{
  "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.

json
{
  "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.

json
{
  "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.

dart
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.

DecisionYour queue does this
sendCall the vendor now. Record the send in history.
deferHold the message until until. Then call decide again and act on that result.
digestAdd the message to the user's batch due at until.
dropDo 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 drop

A 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.

json
{ "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.

  1. Send only the Safe text and data.nid.
  2. The app reads nid from the message.
  3. The app fetches the detail from your API, over your own auth.
dart
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 until as an instant. The vectors write it in the user's local offset, and your engine can return UTC.
  • Treat prefs: null as no record. That means no consent.
  • Treat a missing holidays key as an empty map.
  • Pass now into decide. 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 ​

RuleDefault
Marketing cap3 sends in any rolling 24 hours, the same as the device.
Ignored pushA marketing send with no open, 48 hours after it was sent.
Back-off3 ignored sends in a row pause marketing for 7 days. Each further ignored send doubles the pause, up to 28 days.
Check orderConsent, 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.