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
| Data | Movable | Why |
|---|---|---|
| APNs device tokens | ✅ | Bound to your Bundle ID; your own .p8 sends to them. |
| FCM registration tokens | ✅ | They belong to your Firebase project — OneSignal was using your service account. |
| Web push subscriptions | ❌ | Endpoints are bound to OneSignal's VAPID public key, which they do not export. Re-collect them. |
| Tags, external ids, language, timezone, country | ✅ | From the CSV export. |
| Campaign history | ⚠️ | Aggregate numbers only, not per-event. |
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
| OneSignal | Open Notification |
|---|---|
| App | App |
User / external_id | User with externalId |
| Subscription (push token) | Subscription |
OneSignal.login(id) / logout() | login(externalId) / logout() — same merge semantics |
addTags / removeTags | setTags (merge) / removeTags (null deletes) |
| Data tags in segments | tags.<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 delivery | schedule.type = "user_timezone" |
| Frequency capping | Delivery rules |
| A/B test | A/B testing |
| REST API key | sk_live_… secret key |
| App ID (client) | pk_live_… public key |
| Notification Service Extension | Still 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
deliveredtrackable. Force-stopped apps and aggressive OEM battery savers can drop data messages; the SDK sends withpriority: highto 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.