Webtonative Push Notification API
Send push notifications to your app's users from your own server, and cancel notifications you scheduled earlier.
| Endpoint | Purpose |
|---|---|
POST /api/v1/public/push-notification/send | Send a notification now, schedule it for later, or save it as a draft |
POST /api/v1/public/push-notification/{id}/cancel | Cancel a scheduled notification before it goes out |
All URLs in this document are relative to the Webtonative API base URL, written as {BASE_URL}.
Before you start
You need:
- An organisation API key and secret that have the
WEBTONATIVE_NOTIFICATIONpermission. Create one from your organisation's API key settings in the Webtonative dashboard. The secret is shown only once, so store it safely. - Your app's ID (
appId). The app must belong to the same organisation as the API key. - A plan that includes Webtonative Push. It isn't included on one-time plans. The app's demo or subscription must also still be active.
- Push set up for each platform you send to:
- Android: a Firebase project linked in the Webtonative Push addon.
- iOS: an APNs key added in the Webtonative Push addon. iOS notifications only work once the app has been purchased.
Keep your API secret on your server. Never put it in your website, mobile app or any other client-side code.
Authentication
Send your key and secret in these headers on every request:
| Header | Value |
|---|---|
x-api-key | Your API key |
x-api-secret | Your API secret |
Content-Type | application/json |
| Problem | HTTP status | Response body |
|---|---|---|
| A header is missing, the key or secret is wrong, or the key has expired or been disabled | 401 | { "statusCode": 401, "message": "Unauthorized" } |
The key doesn't have the WEBTONATIVE_NOTIFICATION permission | 403 | { "statusCode": 403, "message": "Forbidden" } |
1. Send a notification
POST {BASE_URL}/api/v1/public/push-notification/send
Request body
| Field | Type | Required | Description |
|---|---|---|---|
appId | string | Yes | Your app's ID. |
title | string | Yes, unless action is DRAFT | Notification title. Up to 120 characters. |
body | string | Yes, unless action is DRAFT | Notification message. Up to 1000 characters. |
launchUrl | string | No | The page that opens in the app when the user taps the notification. Must start with http:// or https://. |
imageUrl | string | No | A public image URL to show in the notification. Must start with http:// or https://. |
platforms | string[] | No | "android", "ios" or both. If you leave this out, every platform that is available and set up for the app is used. Unknown values are ignored. |
sounds | object | No | Custom notification sounds. See Sounds. |
audience | object | No | Who receives the notification. Defaults to everyone. See Audience. |
scheduleType | string | No | "IMMEDIATE" (default) or "SCHEDULED". |
scheduledAt | string | When scheduleType is SCHEDULED | Delivery time in ISO 8601 format, for example "2026-10-20T09:30:00Z". It must be in the future. |
action | string | No | "SEND" (default) sends or schedules the notification. "DRAFT" only saves it as a draft in your dashboard, so nothing is sent. |
Text fields are trimmed before they are checked.
Sounds
Use one of the custom notification sounds already added to your app's build, referenced by its file name.
"sounds": {
"android": { "name": "order_ready.mp3" },
"ios": { "name": "order_ready.wav" }
}
- The name must exactly match a sound added to the app for that platform.
- File names must start with a lowercase letter, contain only lowercase letters, digits and
_, and end in.mp3or.wav. - Leave out a platform, or set it to
null, to use the default system sound.
Audience
Everyone (default when you leave out audience). This sends to every subscribed device on the selected platforms:
"audience": { "type": "EVERYONE" }
Specific users. You describe the users with groups, which is a list of groups where each group is a list of filters:
- A device must match all filters in a group (AND).
- A device only has to match any one group (OR).
"audience": {
"type": "SPECIFIC",
"groups": [
[ { "type": "TAG", "key": "plan", "operator": "IS", "value": "premium" },
{ "type": "LOGIN", "operator": "LOGGED_IN" } ],
[ { "type": "USER", "field": "EMAIL", "operator": "IS", "value": "jane@example.com" } ]
]
}
The example above reaches logged-in users tagged plan = premium, or the user with the email jane@example.com.
Limits: up to 10 groups, and up to 10 filters per group.
Filter types
type | Fields | Matches devices where… |
|---|---|---|
TAG | key, operator: IS | IS_NOT | EXISTS | NOT_EXISTS, value (required for IS and IS_NOT) | The device's tag key equals the value, doesn't equal it, is set, or isn't set. Keys may use letters, numbers, _ and -, up to 64 characters. Values are cut to 256 characters. |
USER | field: EMAIL | EXTERNAL_USER_ID, operator: IS | IS_NOT, value | The user's email or your own user ID matches (or doesn't match) the value. Emails are compared in lowercase. |
LOGIN | operator: LOGGED_IN | ANONYMOUS | The user is logged in (has an external user ID), or isn't. |
CREATED_AT | operator: BEFORE | AFTER, date (ISO date) | The device subscribed before the given day, or after the end of the given day. |
SESSION | field: FIRST_SESSION | LAST_SESSION, operator: LESS_THAN | GREATER_THAN, value (number > 0), unit: MINUTES | HOURS | DAYS | The first or last session happened less than, or more than, the given time ago. For example, LAST_SESSION GREATER_THAN 7 DAYS means "hasn't opened the app in 7 days". |
APP_VERSION | platform: android | ios, operator: IS | IS_NOT, versions (string[], 1–50 items, each up to 64 characters) | The device is on that platform and its app version is (or isn't) one of the listed versions. |
Examples:
{ "type": "TAG", "key": "city", "operator": "EXISTS" }
{ "type": "USER", "field": "EXTERNAL_USER_ID", "operator": "IS", "value": "user_8812" }
{ "type": "CREATED_AT", "operator": "AFTER", "date": "2026-09-01" }
{ "type": "SESSION", "field": "LAST_SESSION", "operator": "GREATER_THAN", "value": 7, "unit": "DAYS" }
{ "type": "APP_VERSION", "platform": "android", "operator": "IS_NOT", "versions": ["1.0.0", "1.0.1"] }
Examples
Send to everyone now:
curl -X POST "{BASE_URL}/api/v1/public/push-notification/send" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-api-secret: YOUR_API_SECRET" \
-d '{
"appId": "YOUR_APP_ID",
"title": "Flash sale is live",
"body": "Up to 50% off for the next 2 hours.",
"launchUrl": "https://example.com/sale",
"imageUrl": "https://example.com/images/sale.png"
}'
Schedule a notification for one user on Android:
curl -X POST "{BASE_URL}/api/v1/public/push-notification/send" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-api-secret: YOUR_API_SECRET" \
-d '{
"appId": "YOUR_APP_ID",
"title": "Your order has shipped",
"body": "Order #1042 is on its way.",
"platforms": ["android"],
"audience": {
"type": "SPECIFIC",
"groups": [[{ "type": "USER", "field": "EXTERNAL_USER_ID", "operator": "IS", "value": "user_8812" }]]
},
"scheduleType": "SCHEDULED",
"scheduledAt": "2026-10-20T09:30:00Z"
}'
Node.js:
const res = await fetch(`${BASE_URL}/api/v1/public/push-notification/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.W2N_API_KEY,
"x-api-secret": process.env.W2N_API_SECRET,
},
body: JSON.stringify({
appId: process.env.W2N_APP_ID,
title: "Flash sale is live",
body: "Up to 50% off for the next 2 hours.",
}),
})
const data = await res.json()
if (!data.isSuccess) throw new Error(data.error)
console.log(data.notification._id, data.notification.status)
Success response
HTTP 201:
{
"isSuccess": true,
"notification": {
"_id": "6703f1c2a9e4b2d1c8e0f123",
"appId": "YOUR_APP_ID",
"source": "API",
"title": "Flash sale is live",
"body": "Up to 50% off for the next 2 hours.",
"launchUrl": "https://example.com/sale",
"imageUrl": "https://example.com/images/sale.png",
"platforms": ["android", "ios"],
"sounds": {},
"audience": { "type": "EVERYONE", "groups": [] },
"scheduleType": "IMMEDIATE",
"scheduledAt": null,
"status": "SENDING",
"stats": {
"targeted": 0,
"accepted": 0,
"failed": 0,
"delivered": 0,
"opened": 0,
"errors": []
},
"createdAt": "2026-10-08T10:15:30.000Z",
"updatedAt": "2026-10-08T10:15:30.000Z"
}
}
Save notification._id. You need it to cancel a scheduled notification.
notification.status tells you what happened:
| Status | Meaning |
|---|---|
SENDING | The notification is being delivered now. Delivery runs in the background, so stats starts at zero. |
SCHEDULED | The notification will be sent at scheduledAt. You can still cancel it. |
DRAFT | Saved as a draft (action: "DRAFT"). Nothing was sent. |
Once delivery is finished, the notification moves to SENT, FAILED or NO_RECIPIENTS. You can see the final status and delivery stats in the Webtonative dashboard.
Error responses
Validation errors are returned with HTTP 201 and isSuccess: false, so always check isSuccess:
{ "isSuccess": false, "error": "Add a title and a message before sending" }
error | Cause |
|---|---|
Title can be at most 120 characters | title is too long. |
Message can be at most 1000 characters | body is too long. |
Launch URL must start with http:// or https:// | launchUrl isn't a valid URL. |
Image URL must start with http:// or https:// | imageUrl isn't a valid URL. |
Invalid delivery time | scheduledAt can't be read as a date. |
Add a title and a message before sending | title or body is empty. |
Select at least one platform | platforms contains no valid platform, or none is available. |
This app has no Android build / This app has no iOS build | The app wasn't built for that platform. |
iOS notifications are available once your app is purchased | You sent to iOS on an unpaid app. |
Set up Android push first — link a Firebase project in the WebToNative Notification addon | Android push isn't set up. |
Set up iOS push first — add your APNs key in the WebToNative Notification addon | iOS push isn't set up. |
Pick a delivery time | scheduleType is SCHEDULED, but scheduledAt is missing. |
Delivery time must be in the future | scheduledAt is in the past. |
"<name>" isn't one of your app's Android notification sounds (or iOS) | The sound doesn't exist in the app, or its file name is invalid. |
audience.type must be EVERYONE or SPECIFIC, Add at least one filter for specific users, Invalid tag operator, … | The audience object is invalid. The message says which part is wrong. |
App access errors come back with an HTTP error status:
| HTTP status | Body | Cause |
|---|---|---|
401 | { "isSuccess": false, "error": "UNAUTHENTICATED_USER" } | appId is missing, doesn't exist, or belongs to a different organisation than the API key. |
403 | { "isSuccess": false, "error": "DEMO_EXPIRED", "message": "The demo period for this app has expired" } | The app's demo has expired. |
403 | { "isSuccess": false, "error": "PLAN_EXPIRED", "message": "The plan for this app has expired" } | The app's subscription has expired. |
403 | { "isSuccess": false, "error": "FEATURE_NOT_ALLOWED", "message": "Webtonative notification is not available on the current plan" } | The app's plan doesn't include Webtonative Push. |
Rate limit
Send calls are limited per app, in 15-minute windows. A window starts at the first call and lasts 15 minutes.
limit per window = max(subscribed devices × 10, 100)
So an app with 5 subscribers can make 100 calls per window, and an app with 2,000 subscribers can make 20,000. The limit is worked out once at the start of each window. Every send call counts towards it, including calls that fail validation.
Each send response includes these headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Calls allowed in the current window |
X-RateLimit-Remaining | Calls left in the current window |
X-RateLimit-Reset | Seconds until the window resets |
When you go over the limit, you get HTTP 429 with a Retry-After header (in seconds):
{
"isSuccess": false,
"errorCode": "RATE_LIMIT_EXCEEDED",
"error": "Rate limit of 100 requests per 15 minutes exceeded. Try again in 412 seconds.",
"retryAfter": 412
}
Tip: Don't make one call per user. One call with an audience filter (for example a
TAGfilter) reaches many users at once.
2. Cancel a scheduled notification
POST {BASE_URL}/api/v1/public/push-notification/{id}/cancel
This cancels a notification whose status is SCHEDULED. Notifications that are already sending, sent or saved as drafts can't be cancelled.
Path parameter
| Parameter | Description |
|---|---|
id | The notification._id returned by the send call. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
appId | string | Yes | The app the notification belongs to. |
Example
curl -X POST "{BASE_URL}/api/v1/public/push-notification/6703f1c2a9e4b2d1c8e0f123/cancel" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-api-secret: YOUR_API_SECRET" \
-d '{ "appId": "YOUR_APP_ID" }'
Success response
HTTP 201:
{ "isSuccess": true }
Error responses
All of these come back with HTTP 201 and isSuccess: false:
| Body | Cause |
|---|---|
{ "isSuccess": false, "error": "Invalid id" } | id isn't a valid notification ID. |
{ "isSuccess": false, "error": "This notification is no longer scheduled" } | The notification doesn't exist in this app, or it isn't SCHEDULED (it has already been sent, was cancelled before, or is a draft). |
{ "isSuccess": false, "error": "UNAUTHENTICATED_USER" } | appId is missing, doesn't exist, or belongs to a different organisation than the API key. |
{ "isSuccess": false, "error": "DEMO_EXPIRED", ... } | The app's demo has expired. |
{ "isSuccess": false, "error": "PLAN_EXPIRED", ... } | The app's subscription has expired. |
{ "isSuccess": false, "error": "FEATURE_NOT_ALLOWED", ... } | The app's plan doesn't include Webtonative Push. |
The cancel endpoint isn't rate limited.
Handling responses
- Check the HTTP status.
401and403mean an authentication or app access problem, and429means you hit the rate limit (wait forRetry-Afterseconds). - Check
isSuccess. If it'sfalse, the request was rejected anderrorsays why. - If it's
true, keepnotification._idandnotification.statusfrom the send response.

