Stock+All posts

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)
Kotlin — client

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,
)
Kotlin — server

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()
Kotlin
external:
  onesignal:
    app-id: ${ONESIGNAL_APP_ID:}       # unset => legacy FCM path
    rest-api-key: ${ONESIGNAL_REST_API_KEY:}
application.yml

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.

The legacy path has to stay fully functional: retries, backoff, stale-token pruning, all of it. A fallback that has quietly rotted is not a fallback.

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(/* ... */))
}
Kotlin — server

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()
}
Kotlin — commonMain

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() }
    }
}
Kotlin — androidMain

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
}
Kotlin — iosMain

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
}
Kotlin — exported iosMain
PushBridgeKt.setPushIdentityHandlers(
    onLogin: { userId in OneSignal.login(userId) },
    onLogout: { OneSignal.logout() }
)
Swift — AppDelegate

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)
}
Kotlin — server

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)
}
Kotlin — commonMain

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")
}
Kotlin — build.gradle.kts

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

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

Try it before you decide.

Free to start, no card required. One AI briefing each morning, a live watchlist, and alerts that actually matter.