Amba

Push Payload Contract

The literal notification payload Amba delivers to devices on iOS (APNs) and Android (FCM) — every field, what is guaranteed stable, and what Amba never injects.

This page is the on-device payload contract: the exact JSON your app receives when Amba delivers a push, field by field, for both platforms. It exists so you can write notification and tap handlers against a documented, stable shape instead of reverse-engineering test pushes with console logs.

The contract in one sentence: the payload contains your title, your body, and your data keys — nothing else of yours, and nothing of ours.

What you send

Every send surface (campaigns, the test push, and per-user scheduled pushes) takes the same three content fields:

FieldTypeBecomes
titlestringThe visible notification title on both platforms.
bodystringThe visible notification text on both platforms.
dataobjectYour custom key/value payload, handed to the app on delivery and on tap.

Amba fans out to APNs for iOS tokens and FCM for Android tokens and builds the platform envelope below for each.

iOS — the APNs payload

Your data keys are placed at the top level of the payload, as siblings of the reserved aps block — not nested under a data key. A campaign created with data: { "screen": "home", "promo": "winback" } arrives on the device as:

{
  // ── your custom data — top-level, exactly the keys you sent ──
  "screen": "home",
  "promo": "winback",
 
  // ── the notification envelope Amba builds ──
  "aps": {
    "alert": {
      "title": "Welcome back!", // your title
      "body": "Your streak is waiting for you.", // your body
    },
    "mutable-content": 1, // always set — a Notification Service
    //                      Extension may modify the notification
  },
}

Field by field:

  • aps.alert.title / aps.alert.body — always present; your title and body verbatim.
  • aps["mutable-content"]: 1 — always present, so an iOS Notification Service Extension (rich media, decryption, badge math) is allowed to run.
  • aps.badge and aps.sound — included only on sends that set them; absent otherwise. Campaign and test sends today carry title / body / data only, so expect exactly the shape above.
  • The aps key is reserved. If your data includes a top-level aps key it is dropped (and a warning is logged server-side) so the notification envelope can never be clobbered or spoofed from custom data.

Alongside the payload, each request is sent with apns-push-type: alert, apns-priority: 10 (immediate delivery), and apns-topic set to your configured bundle id. Campaign sends also set the apns-collapse-id header — see collapse behavior below.

Android — the FCM message

Android delivery uses an FCM v1 message with a notification block (so the system renders it when your app is backgrounded) plus your custom keys in data:

{
  "message": {
    "token": "<device token>",
    "notification": {
      "title": "Welcome back!", // your title
      "body": "Your streak is waiting for you.", // your body
    },
    "android": {
      "priority": "high", // always "high"
      "notification": {
        "tag": "<campaign id>", // campaign sends only — see collapse behavior
      },
    },
    // ── your custom data — ALL VALUES ARE STRINGS (see below) ──
    "data": {
      "screen": "home",
      "promo": "winback",
    },
  },
}

Field by field:

  • notification.title / notification.body — always present; your title and body verbatim.
  • android.priority: "high" — always set, so delivery isn't deferred on dozing devices.
  • android.notification.tag — set to the campaign id on campaign sends (see collapse behavior); absent on test sends.
  • android.notification.sound and android.notification.channel_id — included only on sends that set them; absent otherwise (the device's default channel behavior applies).
  • data — present only when you sent custom data; omitted entirely when you didn't.

Every data value arrives as a string

FCM requires string values in data, so Amba coerces non-string values at the delivery boundary rather than rejecting the send. This bites typed clients: the number you sent arrives as "42", and the object you sent arrives as JSON text you must parse.

You send (campaign data)The device receives
"free""free"
42"42"
true"true"
{ "plan": "pro" }"{\"plan\":\"pro\"}"
["a", "b"]"[\"a\",\"b\"]"
null"null"

Strings pass through unchanged; everything else is JSON-stringified. If a key can hold a non-string value, JSON.parse it in your Android handler (and only there — on iOS the same key arrives with its original JSON type, at the top level of the payload).

What Amba does NOT inject

This is the stability half of the contract:

  • No campaign id, no segment id, no tracking keys are added to data — on either platform, what you send is exactly what arrives (modulo the Android string coercion above).
  • Campaign attribution is tracked server-side, in per-delivery records you can query from the dashboard and API — analytics never ride the notification payload.
  • Your tap handler can therefore rely on your own data keys exclusively, and the payload shape will not change underneath you. Treat any undocumented field as nonexistent; none are added today and none will be added to data.

Collapse behavior (re-sends replace, not stack)

The one place a campaign id appears in transit: campaign sends use it as the platform collapse identifier — the apns-collapse-id request header on iOS and android.notification.tag on Android. Its only effect is that re-sending the same campaign replaces the previous notification in the tray instead of stacking a duplicate. It is never inside data, and test sends don't set it at all.

Reading data in a tap handler

There is no enforced schema for data — your keys land where each platform puts custom data (top-level payload keys on iOS, the string-valued data map on Android), and your notification library surfaces both through one callback. The documented convention is a single deep_link key holding an app route (see the data payload reference):

import * as Notifications from 'expo-notifications';
 
Notifications.addNotificationResponseReceivedListener((response) => {
  const data = response.notification.request.content.data as Record<string, unknown>;
  // iOS: values keep their original JSON types (top-level payload keys).
  // Android: every value is a string — JSON.parse anything structured.
  const target = typeof data.deep_link === 'string' ? data.deep_link : undefined;
  if (target) router.push(target);
});

The full handler walkthrough lives in the SDK push guide.

Next

On this page