Skip to main content

Migrating from OneSignal

Open Notification deliberately mirrors OneSignal's mental model — users with external ids and tags, login/logout, segments, a campaign wizard, delivered/opened/clicked reports — so most of what your team knows keeps working. The hard part of a migration is not the API; it is the tokens.

What can be moved

DataMovableWhy
APNs device tokensBound to your Bundle ID; your own .p8 sends to them.
FCM registration tokensThey belong to your Firebase project — OneSignal was using your service account.
Web push subscriptionsEndpoints are bound to OneSignal's VAPID public key, which they do not export. Re-collect them.
Tags, external ids, language, timezone, countryFrom the CSV export.
Campaign history⚠️Aggregate numbers only, not per-event.
Web push starts from zero

Every browser subscription is cryptographically tied to the VAPID key it was created with. Start collecting subscriptions with your key weeks before you cut over, while OneSignal keeps sending — the two coexist fine.

Mapping the concepts

OneSignalOpen Notification
AppApp
User / external_idUser with externalId
Subscription (push token)Subscription
OneSignal.login(id) / logout()login(externalId) / logout() — same merge semantics
addTags / removeTagssetTags (merge) / removeTags (null deletes)
Data tags in segmentstags.<key> fields in the segment builder
Segments (saved)Segments are inline per campaign; save the JSON yourself if you reuse it
Message → "Send to particular users"POST /v1/notifications with target.externalIds
Intelligent deliveryschedule.type = "user_timezone"
Frequency cappingDelivery rules
A/B testA/B testing
REST API keysk_live_… secret key
App ID (client)pk_live_… public key
Notification Service ExtensionStill required on iOS — ours ships as a template in the iOS SDK

Import tokens from the CSV export

OneSignal's export has these columns: identifier (the push token), device_type (0 iOS, 1 Android, 5 Chrome web…), external_user_id, tags, language, timezone_id, country, last_active, notification_types (-2 means opted out).

Use the dashboard: Subscriptions › Import, format OneSignal, drop the file. Every row goes through the same upsert the SDKs use, so users and tags come out right; opted-out and invalid rows are skipped, web rows with their keys are imported too, and the result card lists rejected lines. See Import subscribers.

If you would rather script it (a custom export, a transformation on the way), the public API does the same thing one POST /v1/subscriptions per row:

// import-onesignal.ts — run with bun
import { parse } from "csv-parse/sync";

const rows = parse(await Bun.file("players.csv").text(), { columns: true });
const API = "https://push.example.com";
const KEY = process.env.PUBLIC_KEY!; // pk_live_…

for (const row of rows) {
if (!row.identifier || row.notification_types === "-2") continue;
if (row.device_type !== "0" && row.device_type !== "1") continue; // web cannot be imported

const tags = row.tags ? JSON.parse(row.tags) : {};
const res = await fetch(`${API}/v1/subscriptions`, {
method: "POST",
headers: { "x-app-key": KEY, "content-type": "application/json" },
body: JSON.stringify({
platform: row.device_type === "0" ? "ios" : "android",
token: row.identifier,
externalId: row.external_user_id || undefined,
tags,
language: row.language || undefined,
timezone: row.timezone_id || undefined,
country: row.country || undefined,
}),
});
if (!res.ok) console.error(row.identifier.slice(-6), await res.text());
}

Throttle it: subscribe is rate-limited per client IP (SUBSCRIBE_RATE_LIMIT, default 10/min) because the public key is not a secret. Raise the limit temporarily on the API for the import, or run the script from the API host itself.

Validate before the first real campaign

Dead tokens in an import make your first delivery rate look terrible. After importing, send a silent campaign to everyone (Silent push in the wizard, or "silent": true with an empty data) — the worker invalidates every 410/Unregistered/UNREGISTERED subscription on the way through, and the report's Failure reasons table tells you how much of the export was already dead.

The parallel-run plan

Week 1–2 Add the Open Notification SDK next to OneSignal's. It collects
tokens and reports events; you send nothing through it yet.
Ship the release and wait for it to reach most users.

Week 3 Once ~90% of sessions are on the new build, import the remaining
OneSignal tokens from the CSV (the API upserts by token, so
devices already registered by the SDK are simply updated).

Week 4 Shift traffic: 10% → 50% → 100% of campaigns. Compare delivery
rate per platform with OneSignal's at each step.

Week 5 Remove the OneSignal SDK. Close the account.

Two SDKs on iOS means two things asking for the APNs token — that is fine; both get the same token. On Android both register a FirebaseMessagingService; only one can receive messages, so either forward onMessageReceived to OpenNotificationMessagingService.handle(message) from OneSignal's service or keep Android sends on OneSignal until week 4.

What is different on purpose

  • Open rate divides by delivered, not sent. OneSignal's numbers will look lower than ours for the same campaign. Ours is the honest one: a push that never arrived cannot be opened.
  • Android messages are data-only. Your app draws the notification (the SDK does this), which is what makes delivered trackable. Force-stopped apps and aggressive OEM battery savers can drop data messages; the SDK sends with priority: high to minimise it.
  • No e-mail, SMS or in-app channels. Push only.
  • Segments are not saved objects. A campaign carries its own segment JSON; copy it between campaigns from the wizard or keep it in your own code.