Notify guides
19 · Migrating from Knock, Courier, OneSignal or Novu

Migrating from Knock, Courier, OneSignal or Novu.

Map the concepts, import users with their devices and preferences, keep web push subscriptions, and run both side by side until you trust it.

What carries over unchanged: your APNs key and Firebase service account (upload the same ones), your web push key pair (import it), device tokens and user ids. What does not: workflow steps beyond delays, unread checks and fallbacks, and features still planned (digests, topics, tenant preferences, SMS and chat) listed below.


1

Concept map

Concepts in other notification services and their Notify equivalents
ElsewhereNotifyNotes
Workflow (Knock, Novu), notification template (Courier), message / journey (OneSignal)Notification typeA category, a template per channel and locale, and routing. No step builder: delays, unread checks and fallbacks are routing fields.
Channel step / channel group, routing (Courier)routing.always + routing.fallbackalways: channels every recipient gets (usually the inbox). fallback: tried in order when the previous step had nothing to deliver or failed.
Delay step, "send if unread"send_at / delay_seconds, after_unread_secondsA fallback step with after_unread_seconds goes out only if the inbox item is still unread.
Cancellation key (Knock), cancel by triggercancellation_keyPOST /notifications/cancel with your key, no stored id needed.
User, subscriber (Novu), profile (Courier), user / subscription (OneSignal)RecipientKeyed by your own user id (external_id). identify() creates or updates it.
identify / setChannelData (Knock), setCredentials (Novu), push tokensrecipients.identify, devicesAPNs and FCM tokens and web push subscriptions are devices; dead ones are pruned for you.
Preference set (Knock), preferences (Novu, Courier)PreferencesCategory × channel and type × channel cells, plus unsubscribe-from-all; defaults on the category.
Tenant (Knock), tenant (Courier)tenant on the sendFilters the inbox. Tenant objects with their own default preferences are planned, not in the preview.
Objects / topics / listsNot in the previewSend to the follower ids yourself (up to 1,000 per call). Topics are planned.
Batch / digest stepNot in the previewBatching and digests are planned. Until then, a per-type throttle caps floods.
In-app feed / inbox component<NotifyInbox/>, @productcraft/notify-jsBell, unread count, real-time list, marks, two action buttons per item.
Public API key + user id (Knock), HMAC subscriber hash (Novu)Recipient tokenYour backend mints a short-lived token per user. Nothing in the browser can act as another user.
Provider integrations (APNs, FCM, SendGrid…)ChannelsAPNs and FCM with your own credentials, web push with keys we generate or you import, email through ProductCraft Mail.

2

Recreate types and categories as code

Most workflows become one type each: an inbox template, a push template, and an email channel naming a Mail template (move the HTML into Mail). A workflow that branches on data becomes two types, chosen by your code.

notify/types.ts
// notify/types.ts: reviewed in pull requests like any other code.
import { Notify } from "@productcraft/notify";

const notify = new Notify();

type TypeBody = Parameters<typeof notify.notificationTypes.replaceByKey>[1];

const categories = [
  { key: "security", name: "Security", transactional: true, defaults: { inbox: "mandatory", email: "mandatory" } },
  { key: "billing", name: "Billing", transactional: true },
  { key: "product", name: "Product updates", defaults: { email: "off" } },
] as const;

const types: Record<string, TypeBody> = {
  "security.new_sign_in": {
    name: "New sign-in",
    category_key: "security",
    critical: true, // ignores opt-outs and quiet hours (never bounces or complaints)
    ttl_seconds: 3600,
    routing: { always: ["inbox"], fallback: [{ channel: "push" }, { channel: "email" }] },
    templates: [
      { channel: "inbox", locale: "en", body: { title: "New sign-in from {{city}}", body: "{{device}} at {{time}}. Not you? Secure your account." } },
      { channel: "inbox", locale: "pt", body: { title: "Novo início de sessão em {{city}}", body: "{{device}} às {{time}}. Não foi você? Proteja a sua conta." } },
      { channel: "push", locale: "en", body: { title: "New sign-in", body: "{{device}} in {{city}}", url: "https://app.example.com/security" } },
      { channel: "email", locale: "en", body: { mail_template_name: "security-new-sign-in" } },
    ],
  },
};

// Apply on deploy. Both calls are upserts: running them twice changes nothing.
const existing = new Set((await notify.categories.list().toArray({ maxItems: 200 })).map((c) => c.key));
for (const c of categories) {
  if (!existing.has(c.key)) await notify.categories.create({ ...c });
}
for (const [key, body] of Object.entries(types)) {
  await notify.notificationTypes.replaceByKey(key, body);
}

3

Import users, devices and preferences

POST /recipients/bulk takes 1,000 recipients per call (20 calls a minute, so 20,000 a minute) with their devices and full preference sets. A bad row comes back with its error and never fails the rest. Device tokens move over as they are: they belong to your apps, not to the provider.

import.ts
import { Notify } from "@productcraft/notify";

const notify = new Notify();

type Row = Parameters<typeof notify.recipients.bulkIdentify>[0]["recipients"][number];

// Your export from the old provider, mapped to Notify rows.
declare const oldUsers: Array<{ id: string; email?: string; locale?: string; apnsTokens: string[]; mutedCategories: string[] }>;

const rows: Row[] = oldUsers.map((u) => ({
  external_id: u.id,
  email: u.email,
  locale: u.locale,
  devices: u.apnsTokens.map((token) => ({ channel: "apns" as const, token, app_id: "com.example.app", environment: "production" as const })),
  preferences: u.mutedCategories.map((key) => ({ scope: "category" as const, key, channel: "*" as const, enabled: false })),
}));

// 1,000 rows per call, 20 calls a minute. A bad row is reported, never fails the batch.
for (let i = 0; i < rows.length; i += 1000) {
  const batch = rows.slice(i, i + 1000);
  const result = await notify.recipients.bulkIdentify(
    { recipients: batch },
    { idempotencyKey: `import:2026-11:${i}` }, // a re-run replays, it does not re-import
  );
  for (const r of result.data) if (r.status === "error") console.warn(r.external_id, r.error);
  await new Promise((resolve) => setTimeout(resolve, 3_000));
}

4

Keep web push subscriptions

Browser subscriptions only work with the VAPID key they were made with. If you brought your own key pair to your provider, or it lets you export one, import it before registering the subscriptions as webpush devices. If the old provider never lets you have the private key, browsers have to subscribe again: call registerWebPush on the next visit of users who had already granted permission (no prompt is shown).

import-vapid.ts
// Your current provider's VAPID key pair (base64url, P-256). Existing
// browser subscriptions were made with this public key, so they keep working.
await notify.channels.webpush.importVapidKeys({
  public_key: process.env.OLD_VAPID_PUBLIC_KEY!,
  private_key: process.env.OLD_VAPID_PRIVATE_KEY!,
});

5

Dual run, then cut over

  1. Send to both with the same deterministic key per event, Notify limited to the inbox, so users see each message once.
  2. Compare the message logs for a week: the counts per type and the reasons for skips. dry_run explains any difference for one user.
  3. Move channels over one at a time (channels: ["inbox", "webpush"]), turning each off on the old side in the same deploy.
  4. Remove the old call, then the old SDK. Delete the old provider’s keys last.
dual-run.ts
import { Notify } from "@productcraft/notify";

const notify = new Notify();
declare const oldProvider: { trigger(workflow: string, opts: { recipients: string[]; data: object; idempotencyKey: string }): Promise<void> };

// Phase 1: send to both, Notify restricted to the inbox (users see one bell).
// Phase 2: move channels over one by one with `channels`, then stop the old call.
export async function commentCreated(commentId: string, recipients: string[], data: Record<string, string>) {
  const key = `comment-created:${commentId}`; // the same deterministic key on both sides
  await oldProvider.trigger("comment-created", { recipients, data, idempotencyKey: key });
  await notify.notifications.send({ type: "comment.created", recipients, data, channels: ["inbox"] }, { idempotencyKey: key });
}

6

Not in the preview yet

  • Batching and digests (use a per-type throttle meanwhile).
  • Topics and lists to subscribe recipients to; tenant objects with default preferences.
  • A hosted preference page (the embeddable <NotifyPreferences/> ships now).
  • SMS, Slack and Teams; a visual workflow builder; Expo push tokens (register native tokens).