OneSignal Push
Send push notifications via the OneSignal HTTP API
The OneSignal Push transport is part of @betternotify/onesignal. It delivers push notifications through the OneSignal HTTP API. It uses plain fetch() with zero external dependencies, so it works in Node.js, Bun, Cloudflare Workers, and any runtime with a global fetch.
The package also ships email and SMS transports — each is independent, pick only what you need.
Getting your API key
- Sign up or log in at onesignal.com.
- Open your app in the dashboard and copy the App ID from Settings → Keys & IDs.
- From the same screen, copy the REST API Key.
Store them as environment variables:
ONESIGNAL_APP_ID=00000000-0000-0000-0000-000000000000
ONESIGNAL_API_KEY=os_v2_app_xxxxxxxxxxxxxxxxxxxxxxxxxInstall
npm install @betternotify/onesignal @betternotify/core @betternotify/pushUsage
import { createNotify, createClient } from '@betternotify/core';
import { pushChannel } from '@betternotify/push';
import { onesignalPushTransport } from '@betternotify/onesignal';
const push = pushChannel();
const rpc = createNotify({ channels: { push } });
const catalog = rpc.catalog({
/* routes */
});
const notify = createClient({
catalog,
transportsByChannel: {
push: onesignalPushTransport({
appId: process.env.ONESIGNAL_APP_ID!,
apiKey: process.env.ONESIGNAL_API_KEY!,
}),
},
});Targeting
Push delivery uses OneSignal subscription IDs (formerly player IDs) via the include_subscription_ids field. Pass them through the to field on RenderedPush.
If you store user external IDs instead, you'll want to add an alias-targeting layer in front of the transport or fetch subscription IDs before sending.
Endpoint: POST /notifications?c=push
Options
Prop
Type
Custom OneSignal fields
The transport maps RenderedPush fields to the OneSignal API automatically. For any OneSignal-specific field not covered by the rendered message, use body (applied to every send) and bodyFor (per-route overrides).
Pass <typeof catalog> as a type parameter to get autocomplete on route names:
const catalog = rpc.catalog({
orderReady: rpc.push().input(/* ... */).title(/* ... */).body(/* ... */),
promoAlert: rpc.push().input(/* ... */).title(/* ... */).body(/* ... */),
});
const transport = onesignalPushTransport<typeof catalog>({
appId: process.env.ONESIGNAL_APP_ID!,
apiKey: process.env.ONESIGNAL_API_KEY!,
body: { ttl: 259200, isIos: true, isAndroid: true },
bodyFor: {
orderReady: { priority: 10, ios_sound: 'order_ready.wav' },
promoAlert: { priority: 5, collapse_id: 'promo' },
},
});Per-send overrides
You can also pass OneSignal fields per-send via the transport key in .send():
await notify.orderReady.send({
to: 'subscription-id-abc',
input: { orderId: 'ORD-4521', storeName: 'Downtown Cafe' },
transport: {
onesignal: { priority: 10, ios_sound: 'alarm.wav' },
},
});Merge order
body (global) → bodyFor[route] (per-route) → transport.onesignal (per-send) → mapped rendered fields (always win).
Common push fields
ttl, priority, collapse_id, ios_sound, ios_interruption_level, ios_badgeType, buttons, web_buttons, url, app_url, web_url, big_picture, chrome_web_image, android_channel_id, included_segments, filters, include_aliases, send_after, idempotency_key
See the OneSignal API reference for the full list of supported fields.
Error handling
The transport maps HTTP status codes to Better-Notify error codes using the shared mapHttpStatus utility:
| HTTP status | Better-Notify code | Retriable |
|---|---|---|
| 400 | VALIDATION | No |
| 422 | VALIDATION | No |
| 401 | CONFIG | No |
| 403 | CONFIG | No |
| 429 | RATE_LIMITED | Yes |
| 5xx | PROVIDER | Yes |
Network failures are wrapped as PROVIDER errors and timeouts as TIMEOUT errors.
Empty-id responses
OneSignal returns HTTP 200 with id: "" when every targeted subscription, address, or phone number is invalid or unsubscribed. The transport surfaces this as a non-retriable VALIDATION error so failover (via multiTransport) and queue retry layers don't waste attempts on impossible sends.