Getting started
From zero to your first push with proof of delivery — through the React Native SDK or straight against the API, with commands you can copy and run.
Getting started
By the end of this page you will have a device registered, a push sent, and — the part that sets PushMesh apart — proof that the message reached the device, not just proof that the provider agreed to deliver it.
Keep these two words in mind; the rest of the documentation uses them constantly:
| Number | What it means |
|---|---|
successful | FCM/APNs accepted the message. Every push service can tell you this. |
recebidos | the device returned proof that the notification arrived. This is what you came here for. |
The hosts
| Host | What it is for |
|---|---|
https://api.pushmesh.io | the public API. It is the address for everything in this reference: your server, the SDK and any custom client talk here |
https://app.pushmesh.io | the dashboard — where the application, the key and the delivery credentials are created |
https://pushmesh.io | the website |
Always pass
baseUrlto the SDK. The version published on npm today (@pushmesh/sdk@0.7.2) has a different host baked in as the default for whenbaseUrlis omitted, and that host is not live. Omit the field and the device never registers, while the server returns no error at all — the call never gets out. The examples on this page passbaseUrlexplicitly, and you should do the same until a new release of the package fixes the default.
Before you start
Three things, all of them from the dashboard at https://app.pushmesh.io:
| What | What it is for | Where to get it |
|---|---|---|
app_id (UUID) | identifies your application in every call | shown in the application’s page in the dashboard |
API key pm_live_… | authenticates calls made by your server | shown exactly once, when the application is created |
| Delivery credentials (FCM and/or APNs) | what actually lets a push leave for the device | dashboard → application settings → FCM / APNs |
The delivery credential is a required step, and what it requires is not obvious. On Android it is the
.jsonfile of a Firebase service account (the legacy server key does not work); on iOS it is the APNs.p8key plus Key ID, Team ID, Bundle ID and the environment choice — where the classic trap lives (TestFlight is production). Without it, the send returns200,recipientslooks right, and nothing goes out. What to have in hand, field by field, is in Delivery credentials — worth reading before you create the account.
The key is shown once. Only a cryptographic digest of it is stored; nobody — support included — can show it to you again. If you lose it, rotate it: rotation keeps the previous key valid for 24 h, so nothing breaks.
Never ship the
pm_live_key inside your published app. An APK or IPA is a file anyone can download and open. The routes the device itself calls (registration, receipts, In-App) were designed to take no credential for exactly this reason: theapp_idtravels in the body, and what protects them is application validation plus a per-origin request cap.
Put the two values in your environment so you can paste the commands below:
export PUSHMESH_KEY="pm_live_a1b2c3d4_put_your_43_character_secret_here"
export APP_ID="00000000-0000-0000-0000-000000000000"
1. Check that you are talking to the API
curl -s https://api.pushmesh.io/
{ "servico": "pushmesh", "versao": "<service version>", "git": "<deployed commit>" }
Now check that your key is accepted. This route needs no parameters — it returns how your application is configured to receive confirmations:
curl -s -H "Authorization: Basic $PUSHMESH_KEY" \
https://api.pushmesh.io/api/v1/confirmacoes
{ "modo": "consulta", "fila_pendente": 0, "streams_ativos": 0 }
401→ the key is missing, incomplete, or does not belong to this account.403→ the key is valid, but the application is paused.200→ you are good to go.
Also notice the X-Pm-Request-Id response header (use curl -i to see it). It
is present on every response, success or failure, and it is the identifier
support will ask for when tracing your call.
2. Register a device
Registering means telling PushMesh: “this push token belongs to this
application”. What comes back is a player_id — the device’s public
identifier, and what you use to send a message to it.
Path A — React Native SDK (recommended)
The package is published on npm and handles registration, token refresh, delivery receipts and click receipts on its own.
npm install @pushmesh/sdk @notifee/react-native @react-native-async-storage/async-storage
cd ios && bundle exec pod install # iOS
import PushMesh from '@pushmesh/sdk';
// once, at app boot
await PushMesh.init({
appId: 'YOUR_APP_ID',
baseUrl: 'https://api.pushmesh.io', // required on 0.7.2 — see the note above
appVersion: '1.0.0',
});
// when the user signs in, so you can target them by your own user id
await PushMesh.login('user-123');
// the device identifier, if you want to target it directly
const playerId = await PushMesh.getPlayerId();
Ask for permission — init does not do it for you
This is the second required step people usually miss. The SDK does not
request notification permission during init, on purpose: your application
chooses when to ask, not the library. On Android 13 or newer and on any iOS,
without that request the device registers fine, the send returns
successful: 1 — and recebidos stays at 0 forever.
// at the right moment in your flow (never in the app's first second)
if (await PushMesh.permissions.canRequestNatively()) {
// the native prompt is still available: one tap, without leaving the app
await PushMesh.permissions.checkAndReport();
} else {
// a decision was already made — only the system settings can reverse it
await PushMesh.permissions.openNotificationSettings();
}
canRequestNatively() is the “smart button”: it returns true only while the
system still accepts the native dialog. Sending someone who has never seen the
prompt straight to Settings is terrible — on iOS, an app that never asked for
permission does not even have a page in Settings, so the person lands at the
root and gets lost.
An honest note about
notification_types: the SDK reads the real system permission and reports changes to the server, but no route in this API currently writes the “blocked” (-2) or “never asked” (0) states. A registered device is born withnotification_types: 1and stays that way; what changes over its life isinvalid_identifier. For “can I still reach this device?”, the truthful question today isinvalid_identifier === false.
What the SDK does from here on with no further code from you:
- registers the device and keeps the token fresh;
- displays the incoming notification;
- sends the delivery receipt when the push arrives and the click
receipt when the user taps — this is what feeds the
recebidosnumber; - queues receipts locally while the device is offline and resends them on the next launch.
If your app already has its own Firebase setup, hand the SDK the token you
already obtain (init({ appId, getToken })) and everything else stays the
same. There is also a command-line check that inspects the integration from
the outside:
npx pushmesh-doctor --base-url https://api.pushmesh.io --app-id "$APP_ID"
Path B — straight against the API
This is the path for a client you write yourself (another platform, an
embedded device, a test). This route takes no Authorization header:
curl -s -X POST https://api.pushmesh.io/api/v1/players \
-H "Content-Type: application/json" \
-d '{
"app_id": "'"$APP_ID"'",
"device_type": 1,
"identifier": "DEVICE_PUSH_TOKEN",
"external_user_id": "user-123",
"app_version": "1.0.0",
"language": "en",
"timezone": -10800
}'
{ "success": true, "id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40" }
That id is the player_id. Keep it.
| Field | Type | Required | Limit | What it does |
|---|---|---|---|---|
app_id | UUID | yes | — | the application that owns the device. Unknown ⇒ 400; paused ⇒ 403. |
device_type | integer | yes | 0 iOS · 1 Android · 5 Chrome · 7 Safari · 8 Firefox | the platform. Any other value ⇒ a named 400. |
identifier | string | yes | 194 bytes | the device push token. Empty or longer ⇒ 400. |
external_user_id | string | no | 128 bytes; up to 10 devices per value | your own person identifier — it lets you target users without knowing their tokens. |
app_version | string | no | — | your application version. |
device_model | string | no | — | device model. |
tags | JSON object | no | 2 KB serialized | free key/value pairs for segmentation. An array or string ⇒ 400. |
language | string | no | 32 bytes | device language. |
timezone | integer | no | ±86400 | offset in seconds (-10800 = UTC−3). A time zone name here ⇒ 400. |
Three behaviours that will save you an afternoon:
- Success is
200, not201— including the very first time. - Registering the same token again returns the same
player_id. The route is idempotent per application + token: call it on every app launch without worrying. - On
POST, an omitted field clears the stored value forexternal_user_id,app_version,device_model,tags,languageandtimezone. Always send the device’s full picture — or make partial changes throughPUT /api/v1/players/{id}, where a missing field never clears anything. Both verbs are covered in Devices.
Testing without a device: an
identifierstarting withtest:(for exampletest:ok) registers normally and returns aplayer_id— useful to validate your registration code. But it is flagged as a sandbox device and is deliberately excluded from every send. To see a real receipt in step 4, use a real device.
3. Send the push
Now with the key, because the sender is your server:
curl -s -X POST https://api.pushmesh.io/api/v1/notifications \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "Content-Type: application/json" \
-d '{
"app_id": "'"$APP_ID"'",
"include_player_ids": ["0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40"],
"headings": { "en": "Order shipped", "pt": "Pedido a caminho" },
"contents": { "en": "It arrives tomorrow", "pt": "Chega amanhã" }
}'
{ "id": "0198e7d1-1c40-7f2a-9b31-77aa10c4e001", "recipients": 1 }
That id is the push. It is also the pm_msg_id that travels inside the
push payload, and it is how the device’s receipt finds its way back.
Minimum rules for the body:
| Field | Type | Required | What it does |
|---|---|---|---|
app_id | UUID | yes | must be the key’s application, otherwise 401. |
contents | object {lang: text} | yes | the message body. An empty object ⇒ 400. |
headings | object {lang: text} | no | the title. |
| one target | — | yes | include_player_ids (up to 2,000), include_external_user_ids (up to 2,000) or included_segments (up to 10 names). None or more than one ⇒ 400. |
For a broad send, swap the target for a segment:
"included_segments": ["Subscribed Users"]
Accepted names are Subscribed Users (and its synonym Total Subscriptions), Active Users (seen in the last 7 days) and Engaged Users
(returned a receipt in the last 7 days). An unknown name returns 400 listing
the valid ones.
Rehearse first, if you like. With the X-Dry-Run: true header the call
resolves the real audience, returns the real recipients, and delivers
nothing and stores nothing:
curl -s -X POST https://api.pushmesh.io/api/v1/notifications \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "X-Dry-Run: true" \
-H "Content-Type: application/json" \
-d '{"app_id":"'"$APP_ID"'","included_segments":["Subscribed Users"],"contents":{"en":"test"}}'
{ "id": "", "recipients": 12840, "dry_run": true }
Two 200 responses that look like errors and are not
{ "id": "", "recipients": 0, "errors": ["All included players are not subscribed"] }
Nobody in the target was reachable. This is not a 4xx and retrying will
not help: either the identifiers do not exist in this application, or the
devices are not subscribed, or they were sandbox devices.
{
"id": "0198e7d1-1c40-7f2a-9b31-77aa10c4e001",
"recipients": 1,
"errors": { "invalid_player_ids": ["11111111-1111-1111-1111-111111111111"] }
}
A partial send: the valid ones went out, and the response names the ones that
did not. Note that errors is an object here and was an array above —
if you deserialize it into a fixed type, accept the union.
4. Read the receipt
curl -s -H "Authorization: Basic $PUSHMESH_KEY" \
"https://api.pushmesh.io/api/v1/notifications/0198e7d1-1c40-7f2a-9b31-77aa10c4e001?app_id=$APP_ID"
{
"id": "0198e7d1-1c40-7f2a-9b31-77aa10c4e001",
"successful": 1,
"failed": 0,
"errored": 0,
"remaining": 0,
"converted": 0,
"recebidos": 1,
"queued_at": 1756300000,
"completed_at": 1756300003,
"contents": { "en": "It arrives tomorrow", "pt": "Chega amanhã" },
"platform_delivery_stats": {
"android": { "successful": 1, "failed": 0 },
"ios": { "successful": 0, "failed": 0 }
},
"pm_metricas": { "via": "rest", "cobertura_recibo_pct": 100.0, "ttfd_ms": null, "duracao_ms": 3000 }
}
The example above is abridged: the response also carries the title, the target and the scheduling window. The full list is in Delivery receipts.
| Field | What it means |
|---|---|
successful | the provider accepted it. Not proof of arrival. |
failed | permanent rejection (a dead token, for instance). |
errored | all three delivery attempts were exhausted. |
remaining | still being processed. It must reach zero. |
converted | clicks confirmed by the device. 0 means nobody tapped. |
recebidos | how many devices proved they received it. |
pm_metricas.cobertura_recibo_pct | recebidos ÷ successful, as a percentage. |
Two honest notes about these numbers:
- Coverage rarely reaches 100%. A device that is switched off, offline, or no longer has your app installed returns no proof at all. A number below 100% is an honest picture of your base, not a platform defect.
recebidos: nullis not zero. On the free plan the field comes backnullalongside arecebidos_gateblock explaining that real delivery confirmation is a paid-plan feature. If your code doesrecebidos ?? 0, a commercial gate silently becomes “zero deliveries” on your dashboard.
5. Who sends the receipt
If you use the SDK, nobody: it already did. This section is for people writing their own client.
Every push carries two fields inside data: pm_msg_id (the push) and
pm_rcpt (a cryptographic proof valid only for that push on that device).
When the notification arrives, your client echoes both back:
curl -s -X POST https://api.pushmesh.io/api/v1/receipts \
-H "Content-Type: application/json" \
-d '{
"app_id": "'"$APP_ID"'",
"notification_id": "PM_MSG_ID_FROM_THE_PUSH",
"player_id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40",
"rcpt": "PM_RCPT_FROM_THE_PUSH",
"evento": "recebido"
}'
{ "ok": true }
eventoacceptsrecebido(the default) andclique. Acliquewith no prior receipt counts as both — nobody taps what never arrived.- Sending the same receipt again returns
{"ok":true,"duplicado":true}: you can drop the item from your queue. {"ok":true,"tardio":true}means “counted, through a slower path” — it is also a success, and must not be treated as a duplicate.- Do not try to compute the
rcpt. The key that produces it never leaves the server; echo exactly what arrived in the push. An invalid proof returns403, always with the same wording.
6. If nothing arrived
Work through this in order — it is the order in which things usually break:
| Symptom | Likely cause |
|---|---|
| No device registered, and no error anywhere in your logs | the SDK has no baseUrl and talked to its built-in default host, which is not live. Pass baseUrl: 'https://api.pushmesh.io' to init. |
recipients: 0 with an empty id | the target does not exist in this application, the devices are not subscribed, or they were sandbox (test:) devices. |
good recipients, successful: 0, remaining: 0 | the delivery credential (FCM/APNs) is missing or wrong in the dashboard. See Delivery credentials. |
good successful, recebidos: 0 | the push went out but the device returned no proof: notification permission denied, app uninstalled, device offline — or your own client is not calling the receipts route. |
403 mentioning app pausado | the application is paused on the account. |
422 mentioning bytes | the rendered payload exceeded 3,891 bytes. The budget includes 96 bytes the server injects (pm_msg_id + pm_rcpt); the error tells you how many bytes to trim. |
422 mentioning plano and usuários ativos | the account is on the free plan and went past its monthly allowance. The send is refused entirely, on purpose, and retrying does not help — explain carries the cap, the current number and the price of leaving the free plan. See Limits. |
429 | you hit a cap. See Authentication and errors. |
| any error you do not recognise | read explain.causa and explain.como_corrigir in the body, and keep the request_id. |
The
errorsandexplaintexts are written in Portuguese, with no language negotiation. Branch your code on the HTTP status and the structured fields — never on the message string.
What to do next
| If you want to… | Go to |
|---|---|
| understand the key, rotation and every error | Authentication and errors |
| get your FCM and APNs credentials in order | Delivery credentials |
| every send field (scheduling, image, priority, idempotency) | Send a push |
| the delivery proof in depth, history and windowed numbers | Delivery receipts |
| manage and export the device base | Devices |
| be notified instead of polling | Webhooks |
| messages inside the application | In-App messages |
| migrate from another push service | Migrating from another provider |
| every cap, with numbers | Limits |
| retention, personal data and deletion | Data, retention and privacy |
| know what can change without notice | Versioning |