Handling opens and deep links
Every SDK normalises a press into one message and reports the event before your handler runs. Your job is to route.
The message, again
| Field | Use it for |
|---|---|
url | The deep link. If a button with its own url was pressed, this is the button's. |
data | The campaign's data map — screen, ids, anything you set. Flat strings only (FCM data allows nothing else). |
actionId | Which button, when any. |
msgId, campaignId | Analytics correlation. |
Prefer data.screen + ids for in-app navigation and url for links that may leave the app or be shared.
Routing patterns
React Native
OpenNotification.onOpen(({ url, data, actionId }) => {
if (actionId === "later") return; // a "remind me" button
if (data.screen) navigation.navigate(data.screen, data);
else if (url) Linking.openURL(url);
});
Swift
OpenNotification.shared.onOpen = { message in
if let screen = message.data["screen"] { router.show(screen, params: message.data) }
else if let url = message.url.flatMap(URL.init) { UIApplication.shared.open(url) }
}
Kotlin
OpenNotification.onOpen = { message ->
message.data["screen"]?.let { router.show(it, message.data) }
?: message.url?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }
}
Web — the service worker opens data.url (or the button's data["a:<id>"]), focusing an existing tab on that path first. To handle it in-page, put what you need in the URL's query string and read it on load.
Cold starts
| Platform | How the press reaches you |
|---|---|
| iOS (Swift) | handleLaunch(userInfo:) in didFinishLaunching — replayed into onOpen once it is set. |
| Android (Kotlin) | OpenNotification.messageFrom(intent) in onCreate / onNewIntent. |
| React Native | Held natively until JS is ready, then delivered to onOpen. |
| Web | The worker opens/focuses the URL; the page loads normally. |
Action buttons
Defined per locale in the content (actions: [{ id, title, url? }], max 3). How each platform shows them:
| Buttons shown | Where the button's URL goes | |
|---|---|---|
| iOS | up to 4 (we send ≤ 3); needs the NSE to register the category | message.url |
| Android | 3 | message.url |
| Chrome / Edge | 2 | data["a:<id>"] |
| Safari | none | — |
A button press reports clicked, a body press opened. Both count toward the funnel; only clicked counts as CTR.
Foreground arrivals
onReceive fires when a push lands while the app is open. The SDKs still show the system notification (iOS via willPresent, Android by drawing it); use onReceive to refresh a badge or a list, not to show a second alert.
The tracking endpoint
The SDKs call it for you; documented here for custom integrations:
POST /v1/e/d/<msgId> delivered
POST /v1/e/o/<msgId> opened
POST /v1/e/c/<msgId> clicked
POST /v1/e/x/<msgId> dismissed
No headers, no body, no key. The msgId is HMAC-signed and embeds appId|campaignId|userId|subscriptionId|platform|variant, so the API verifies it without a database read, writes the event, bumps the campaign (and variant) counters and emits the webhook. Rate-limited per IP (EVENT_RATE_LIMIT). A replayed ping only moves analytics counters; it grants nothing.