Send one push to every device, a list of devices or subscribers, or a group, and read the result per transport.
POST https://app.nativenotify.com/api/universal/notifications/send
Request
curl -X POST https://app.nativenotify.com/api/universal/notifications/send \
-H "Content-Type: application/json" \
-d '{
"appId": 123,
"appToken": "yourAppToken",
"title": "Service this Sunday",
"message": "Doors open at 9:00 — the 10:30 service is unchanged.",
"audience": { "type": "all" }
}'
const res = await fetch("https://app.nativenotify.com/api/universal/notifications/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: 123,
appToken: "yourAppToken",
title: "Service this Sunday",
message: "Doors open at 9:00 — the 10:30 service is unchanged.",
audience: { type: "all" },
}),
});
const send = await res.json();
console.log(send.accepted, "accepted of", send.attempted);
import requests
send = requests.post(
"https://app.nativenotify.com/api/universal/notifications/send",
json={
"appId": 123,
"appToken": "yourAppToken",
"title": "Service this Sunday",
"message": "Doors open at 9:00 — the 10:30 service is unchanged.",
"audience": {"type": "all"},
},
timeout=30,
).json()
print(send["accepted"], "accepted of", send["attempted"])
| Field | Required | Notes |
|---|---|---|
appId, appToken | yes | Your app's pair. |
title | yes | Notification title. |
message | yes | Body text. body is accepted as an alias. |
audience | yes | Who receives it. See Audiences. |
pushData | no | A JSON object (or JSON string) delivered to the app with the notification, for deep links and ids. |
bigPictureURL | no | Absolute https:// image URL, up to 2000 characters; anything else is 400 invalid_field. See Images. |
environment | no | "production" (default), "staging" or "development". Reaches only devices in that environment. |
idempotencyKey | no | Up to 200 chars. A retry with the same key writes no duplicate inbox entries. The push itself is never deduplicated. |
skipInbox | no | true writes no inbox entries for this send; the push and analytics are unchanged. A non-boolean is 400 invalid_field. |
| Rich fields | no | subtitle, badge, ttl, expiration, interruptionLevel, categoryId, channelId, collapseId, contentAvailable, mutableContent, sound, as in Rich notifications. |
Sends are live and can't be recalled, so test with a devices audience of one device you own.
Audiences
{ "audience": { "type": "all" } }
Every registered device, with or without a subscriberId.
{ "audience": { "type": "devices", "deviceIds": ["A1B2C3D4-install-key", "E5F6-install-key"] } }
The listed deviceIds, whether or not they have a subscriberId.
{ "audience": { "type": "subscribers", "subscriberIds": ["user_8241", "user_8242"] } }
Every device registered under the listed subscriberIds.
{ "audience": { "type": "group", "key": "weekly-news" } }
Every device subscribed to a group, by subscriber id or device key. An unknown or empty group sends to nobody (devices: 0), not an error.
On Premium, a group audience, or a devices or subscribers list of more than one id, answers 403 plan_upgrade_required and sends nothing (what each plan includes).
Images
bigPictureURL also works on test sends and schedule rules. Blank means no image.
| Transport | What the push carries | What your app needs |
|---|---|---|
| iOS (APNs) | mutable-content: 1 and the URL in the payload's image key. | A notification service extension that downloads it. See the Swift recipe. |
| Android (FCM) | A notification block with the image. Firebase draws it while the app is closed or in the background. | Code that draws it while the app is open, and two manifest defaults. See the Kotlin recipe. |
| Web | image in the service worker's payload. Dropped first when the payload exceeds 3,993 bytes. | image: payload.image in showNotification. See Web push. |
Use an https JPEG or PNG under 1 MB. iOS also shows a GIF, up to 10 MB, and does not show WebP. The device fetches the image, so host it anywhere public. If it fails to load, the notification still arrives without it.
The iOS Simulator never runs notification service extensions, so a push there shows its text without the picture. Test images on an iPhone.
Response
{
"ok": true,
"environment": "production",
"sendId": 1000000412,
"devices": 1284,
"attempted": 1301,
"accepted": 1298,
"failed": 3,
"byType": {
"apns": { "attempted": 640, "accepted": 639, "failed": 1, "delivered": null },
"fcm": { "attempted": 641, "accepted": 639, "failed": 2, "delivered": null },
"web": { "attempted": 20, "accepted": 20, "failed": 0, "delivered": null }
},
"skipped": { "apns": 0, "fcm": 4 },
"health": {
"apns": { "struck": 1, "dead": 0 },
"fcm": { "struck": 2, "dead": 0 },
"web": { "struck": 0, "dead": 0 }
},
"inboxSkipped": false,
"note": "accepted = what the transport accepted at send time. delivered stays null — APNs / FCM cannot prove delivery and this service never fabricates it.",
"checkedAt": "2026-09-23T09:30:00.000Z"
}
| Field | Meaning |
|---|---|
sendId | The nn_notification_id the push carries for open analytics. null if the analytics record couldn't be written; the send still went out. |
devices | Devices the audience resolved to. A device with several tokens counts once. |
attempted, accepted, failed | Tokens sent to, accepted by the transport, and rejected. |
byType | The same split per transport. delivered is always null: no transport can prove delivery. |
skipped | APNs and FCM tokens with no credentials saved. A setup gap, never counted against the token. Web push needs none. |
health | Per transport: tokens that took a failure strike (struck) and tokens this send retired (dead). |
inboxSkipped | Echoes skipInbox. |
Only dead-token answers (BadDeviceToken, Unregistered, FCM UNREGISTERED, web 404 or 410) add a strike. Credential and transient errors never do. Each targeted device also gets one inbox entry, written in the background, so list it after a short delay.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | invalid_body, missing_field, invalid_field | Bad JSON, a missing appId, title or message, or an invalid appId, rich field, bigPictureURL, idempotencyKey or skipInbox. |
400 | missing_audience, invalid_audience | No audience, an unknown type, an empty id list, or a missing group key. |
400 | invalid_environment | Not production, staging or development. |
403 | trial_expired | The free trial ended, or sending is paused for an unpaid invoice (details). |
403 | plan_upgrade_required | A group or multi-id audience on Premium. |
403 | environment_disabled | The target environment is switched off for this app. |
404 | app_not_found | The app isn't one this caller can access. |
Every 403 means nothing was sent. A wrong appId and appToken pair answers 401 with a plain-text message, like the rest of the API.