Shipping OneSignal push in a Kotlin Multiplatform app
·8 min read
Key takeaways
- OneSignal's unit of addressing is the user (external_id), not the device token. The server stops holding a token table, a refresh callback, a pruning job, and a fan-out loop.
- Persist a durable inbox row before attempting the push. Push delivery is best-effort by platform contract, so durable-first turns a delivery failure into a missed buzz rather than lost product data.
- Kotlin cannot see a Swift package. Invert control: Kotlin holds nullable closures, Swift assigns them at startup — and the object must live in an exported module or its symbols never reach the framework header.
- OneSignal returns HTTP 200 with a populated `errors` field when no subscribed device matches the external id. Checking `response.isSuccessful` reports permanent success while delivering nothing.
- OneSignal 5.9.8 supports firebase-messaging up to 24.0.99, but the Firebase BOM forces 24.1.1 and Gradle resolves to the highest version, not a satisfying one. The build stays green; the FCM registrar dies on device with FIREBASE_FCM_INIT_ERROR.
Most of a push-notification implementation has nothing to do with the product. Token tables, refresh callbacks, stale-token pruning, a separate iOS pipeline — none of it is the feature, and all of it needs maintaining.
StockPlus is a Kotlin Multiplatform app whose core product is price alerts, delivered as push notifications. This is how we moved it from direct Firebase Cloud Messaging to OneSignal: what actually changed architecturally, and the four silent failures that had to be hunted down along the way.
Addressing users, not devices
Direct FCM builds a message around a registration token. One token, one device. That sounds simple, but it quietly generates a lot of server-side work: a table of tokens per user, a refresh callback because tokens rotate, pruning on UNREGISTERED because they go stale, a hand-written fan-out loop because a phone and a tablet are two rows, and an entirely separate iOS pipeline with separate credentials.
OneSignal inverts the unit of addressing. The client declares an identity:
OneSignal.login(userId)The server then addresses that identity instead of a device:
mapOf(
"app_id" to appId,
"target_channel" to "push",
"include_aliases" to mapOf("external_id" to listOf(userId)),
"headings" to mapOf("en" to title),
"contents" to mapOf("en" to body),
"data" to data,
)One HTTP call reaches every device where that user is logged in, on both platforms, and the server holds no device state at all. The real change is the unit of addressing, not the vendor. The migration removed more lines than it added, which is usually a good sign — deleted code has no bugs and needs no tests.
No flag day
The pipeline was already live and price alerts are the product, so a big-bang cutover was out. The new channel went in beside the old one, selected purely by configuration:
val isEnabled: Boolean
get() = appId.isNotBlank() && restApiKey.isNotBlank()external:
onesignal:
app-id: ${ONESIGNAL_APP_ID:} # unset => legacy FCM path
rest-api-key: ${ONESIGNAL_REST_API_KEY:}Deploying the code is not the cutover: the binary behaves exactly as before until the credentials are set. Rollback is an environment variable rather than a revert. Local dev and CI have no credentials, so they transparently use the legacy path and nothing ever accidentally sends from a laptop. Note which way the default points — doing nothing gets you the old, proven behaviour.
Durable first, push second
This is the design decision worth defending hardest, and it applies whichever vendor you pick: a push notification is not the notification. It is an announcement that a notification exists.
Every send path persists a durable inbox row first, then attempts the push:
fun sendAlertTriggered(
fcmToken: String?,
ticker: String,
alertType: AlertType,
price: BigDecimal?,
userId: UUID,
) {
val (title, body) = buildAlertMessage(ticker, alertType, price)
// Inbox is the source of truth; the push below is best-effort.
notificationRepository.save(userId, title, body, alertType.name, ticker)
deliverPush(fcmToken, userId, title, body, mapOf(/* ... */))
}Push delivery is genuinely unreliable, and not because the vendors are bad at it: denied permissions, offline devices, OS throttling, rotated tokens, guest users. On iOS, best-effort delivery is the explicit platform contract. Ordering it durable-first turns each of those failures from lost product data into a missed buzz — the alert is sitting in the inbox when the user next opens the app. It also lets the whole push layer be best-effort all the way down: no retries blocking a request, no transaction spanning an HTTP call, no error a user can ever see.
One interface, opposite directions
Shared code depends on a plain interface. expect class would work here, but a plain interface does the same job and stays mockable in tests for free:
interface PushIdentityBinder {
fun login(userId: String)
fun logout()
}Android is the easy one: the OneSignal SDK is a Gradle dependency, so the implementation calls it directly. It never throws — any vendor surprise degrades to “no push”, never to “sign-in crashed”:
class AndroidPushIdentityBinder : PushIdentityBinder {
override fun login(userId: String) {
runCatching { OneSignal.login(userId) }
}
override fun logout() {
runCatching { OneSignal.logout() }
}
}iOS is where it gets interesting. The OneSignal iOS SDK is a Swift package, and Kotlin cannot see it — Swift sees Kotlin through the generated framework, but not the reverse. So on iOS the control flow is inverted: Kotlin holds the closures, and Swift fills them in at startup.
object IosPushIdentityBridge {
var onLogin: ((String) -> Unit)? = null
var onLogout: (() -> Unit)? = null
}One wrinkle costs a confusing hour the first time. The module holding that object is an implementation dependency of the iOS framework rather than an exported one, so its symbols never appear in the framework header and Swift cannot see the bridge at all. The fix is a thin re-export in the module that is exported:
fun setPushIdentityHandlers(
onLogin: (String) -> Unit,
onLogout: () -> Unit,
) {
IosPushIdentityBridge.onLogin = onLogin
IosPushIdentityBridge.onLogout = onLogout
}PushBridgeKt.setPushIdentityHandlers(
onLogin: { userId in OneSignal.login(userId) },
onLogout: { OneSignal.logout() }
)That call has to run before the root component spins up. A cold start with a saved session binds identity immediately, and getting the order wrong silently no-ops on exactly the launch that matters most: a returning, logged-in user.
Desktop binds a no-op. Three platforms, three strategies — direct call, inverted callback, deliberate nothing — behind one interface with zero conditionals in shared code.
Nothing threw, nothing was red
Push is a pipeline of best-effort steps, which means its default failure mode is silence. Four silent failures turned up.
The successful failure. OneSignal returns HTTP 200 with an errors field when no subscribed device matches the external id, so a naive response.isSuccessful check reports permanent success whilst delivering nothing, forever. The fix is to parse the body:
val errors = objectMapper.readTree(responseBody).path("errors")
if (!errors.isMissingNode && errors.size() > 0) {
log.info("OneSignal delivered nothing for userId={}: {}", userId, errors)
}Transport success is not application success.
The misconfiguration in camouflage. The legacy path had two skip conditions with byte-identical behaviour: Firebase never initialised (someone forgot an env var), and no token on file (completely normal for guests). Both silently sent nothing. Now the first logs a warn naming the exact variable to check, and the second logs debug. When a broken configuration and a normal condition produce the same behaviour, they must not produce the same log.
The early return that only breaks one platform. This one nearly shipped:
suspend operator fun invoke(token: String? = null): AppResult<Unit> {
// Identity binding FIRST — it needs only the userId. On iOS the FCM token
// is always null; OneSignal is the only push channel there.
sessionManager.currentUserId()?.let(pushIdentityBinder::login)
val resolvedToken = token ?: pushTokenProvider.getToken()
if (resolvedToken.isNullOrBlank()) {
return AppResult.Error("No push token available", "NO_PUSH_TOKEN")
}
return pushTokenRepository.registerToken(resolvedToken, pushTokenProvider.platform)
}The obvious ordering — fetch the token, bail if null, then do the rest — gates identity binding behind a token that is always null on iOS. Android works perfectly; iOS never calls login(), never matches a send, and reports no error anywhere. In shared multiplatform code an early return guards everything after it on every platform, so it is worth asking whether the guard’s precondition is even meaningful on all of them.
The transitive dependency. The OneSignal dashboard showed zero Android recipients whilst iOS delivered fine. The SDK’s verbose logging showed FIREBASE_FCM_INIT_ERROR— the device had never subscribed at all. The dependency tree explained why: OneSignal 5.9.8 supports firebase-messaging [23.0.8, 24.0.99], but an unrelated Firestore feature pulled in the Firebase BOM, which forced 24.1.1. Gradle’s conflict resolution picks the highest version, not one satisfying every constraint, so the build stayed green and the registrar died on real devices.
configurations.configureEach {
resolutionStrategy.force("com.google.firebase:firebase-messaging:24.0.0")
}That block is declared in three modules — the push module itself, the shared entrypoint, and the Android application module — and the duplication is load-bearing: resolutionStrategy only governs the declaring module, and it is the application module that resolves the classpath actually shipping in the APK. Your version catalog records what you asked for; ./gradlew :entrypoint:android:dependencies records what you got.
Two traps
- Do not declare your own
MESSAGING_EVENTservice. It wins over the one OneSignal merges in from its AAR, and data-only pushes get silently dropped. Our manifest carries a permanent comment saying so, because there is no lint check for code that must not exist. - Call
OneSignal.logout()beforeclearing the session — it needs the outgoing access token. Reverse the order and a signed-out phone keeps receiving the previous account’s price alerts. That is not a missing-notification bug, that is a data leak, one line of ordering away.
What actually mattered
The SDK calls really are two lines, and the two lines were never the work. What mattered was picking the addressing model before the vendor, making push best-effort by making something else durable first, migrating behind configuration with the safe path as the default, and hunting silent failures deliberately: verbose vendor logging on day one, parsing bodies rather than trusting status codes, and giving misconfiguration a louder log than normal operation.
Very little of this is OneSignal-specific. The durable-first contract, the config-flag migration, and the control-flow inversion apply to any platform SDK your shared Kotlin code cannot see.
Frequently asked questions
How do you send a OneSignal notification to a specific user rather than a device?
Call OneSignal.login(userId) on the client to bind an external id to the subscription, then address that id from the server with include_aliases: {"external_id": [userId]} and target_channel: "push". One request reaches every device where that user is logged in, on both platforms, and the server stores no device tokens.
What causes FIREBASE_FCM_INIT_ERROR with OneSignal on Android?
Most often a firebase-messaging version outside the range the OneSignal SDK supports. OneSignal 5.9.8 declares support up to 24.0.99; pulling in the Firebase BOM for an unrelated feature can force 24.1.1, and Gradle's conflict resolution selects the highest version rather than one satisfying every constraint. The build succeeds and the FCM registrar fails at runtime, so the device never subscribes. Forcing firebase-messaging to 24.0.0 in the module that resolves the shipped classpath fixes it.
How do you call a Swift-only SDK from shared Kotlin code in KMP?
You cannot call it directly — Swift can see Kotlin through the generated framework, but Kotlin cannot see Swift. Invert the direction: declare an object in Kotlin holding nullable function properties, expose a setter from a module that is exported (not merely an implementation dependency, or the symbols never appear in the framework header), and assign the closures from Swift during app startup before any code path needs them.
Should a push notification be the source of truth for an in-app notification?
No. Treat the push as an announcement that a notification exists, and persist the notification itself first. Denied permissions, offline devices, OS throttling, rotated tokens and signed-out users all drop pushes, and on iOS best-effort delivery is the stated platform contract. Writing the durable row first means a failed push costs a buzz, not the record.
How do you migrate a live push pipeline to a new vendor without a cutover?
Gate the new path on the presence of its own credentials — if the app id and API key are blank, the code takes the legacy path. Deploying then changes nothing, enabling is an environment variable rather than a release, rollback is unsetting it, and local dev and CI cannot accidentally send because they have no credentials. The condition must default to the proven path, and the legacy path has to be kept working rather than left to rot.
Keep reading
- Why stock price alerts fail when your app is closed
The alert was set correctly and the notification still never came. The reason is structural, and it comes down to one question: which computer was supposed to check the price?
- How to read an SEC Form 4 filing
Almost all of a Form 4's meaning sits in one letter. Get it wrong and a routine tax withholding reads as an executive dumping stock. Here is every code, transcribed from the SEC's own form.
- When companies report earnings, and why the calendar looks the way it does
Why does nobody report a fourth quarter? Why do after-the-bell releases start at 4:05pm rather than 4:00? Both answers are in the rulebooks, and neither is a market convention.
- The best congress stock trackers in 2026
Every list ranking for this term puts its own product first without saying so, and prints prices with no date. Nine trackers, a stated methodology, and four prices we could not verify — said out loud.
- The STOCK Act's 45-day rule, and how late congress actually files
Everyone quotes the rule as '45 days'. The statute sets a two-part test, and the difference decides whether a given filing was late at all.
- How to track congress stock trades (2026)
The filings are free and public, and almost nobody reads them — because the portals are miserable. Here is what is in a disclosure, what is missing, and the three ways to follow them.
- How to set stock price alerts that you will actually act on
Most alerts fail for one reason: they fire on noise. Here is how to pick the trigger type, the threshold, and the number of alerts you can actually live with.
- The best stock alert apps in 2026
Nearly every finance app claims alerts. The real differences are trigger types, free-tier limits, and whether alerts fire when the app is closed. Six apps, compared honestly.
Try it before you decide.
Free to start, no card required. One AI briefing each morning, a live watchlist, and alerts that actually matter.