Devices
Register, identify the user, update, read and export devices — including the one rule that breaks most first integrations.
Devices
A device is a phone or browser that can receive a push. Each one has a
public identifier — the player_id, a UUID — and that is what you target when
you send.
These routes do four things: register a device, say who is using it, read its state, and export the whole base.
In 30 seconds
curl -X POST https://api.pushmesh.io/api/v1/players \
-H 'Content-Type: application/json' \
-d '{
"app_id": "01926f3a-4b2c-7d8e-9f01-23456789abcd",
"device_type": 1,
"identifier": "DEVICE-PUSH-TOKEN",
"external_user_id": "user-4711"
}'
{ "success": true, "id": "01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6" }
The id in the response is the player_id. Keep it: it is what goes into
include_player_ids when you send, and what the device echoes back in the
delivery receipt.
The response is
200, never201— including on the very first registration. The route is idempotent per(app, token): calling it a hundred times with the same token always returns the sameplayer_id.
The four routes, and who calls each
| Route | Called by | Credential |
|---|---|---|
POST /api/v1/players | the app, on the device | none |
PUT /api/v1/players/{id} | the app, on the device | none |
GET /api/v1/players/{id} | your server | app key |
POST /api/v1/players/csv_export | your server | app key |
The first two are public by design: a published app is a published secret, so your API key never has to ship inside an APK or IPA. What protects those routes instead of a key:
- the
app_idin the body is checked against the database before any write — unknown returns400, paused application returns403; - field-by-field shape validation, before touching the database;
- a body of at most 16 KB;
- a cap of 120 requests per minute per source IP, with
Retry-After: 60.
The last two require the app key in the Authorization header, and the
app_id in the query string must be the same app as the key — if it isn’t, the
answer is 401, never 404 (the API never confirms the existence of another
customer’s resource).
Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1. Register a device
POST https://api.pushmesh.io/api/v1/players
Call it every time the app opens. The route upserts: a new token creates, a
known token updates, and the player_id returned is always the same.
Required fields
| Field | Type | Limit | What it does |
|---|---|---|---|
app_id | UUID | — | The application. Must exist and be active. |
device_type | integer | 0, 1, 5, 7 or 8 | Platform: 0 = iOS, 1 = Android, 5 = Chrome/web, 7 = Safari, 8 = Firefox. Any other value is rejected by name. |
identifier | string | 1 to 194 bytes | The device push token. It is the deduplication key. |
The 194-byte cap on identifier exists to fail loudly at the door: a real token
is 64 to ~170 characters, and anything larger is almost always a whole payload
pasted into the wrong field. The error tells you how many bytes arrived.
Optional identity and content fields
| Field | Type | Limit | What it does |
|---|---|---|---|
external_user_id | string | 128 bytes | Your own person identifier (account id, hashed email…). Lets you target by user instead of by device. Maximum 10 devices per value. |
app_version | string | — | Your app version. The legacy name game_version is also accepted; if both arrive, app_version wins. |
device_model | string | — | Device model, as free text. |
tags | JSON object | 2 KB serialized | Your own key/value pairs. Must be an object — a list or a string is rejected. |
language | string | 32 bytes | Short language code ("pt", "en"). |
timezone | integer | ±86400 | Time zone offset in seconds (e.g. -10800). A zone name here is an error. |
transporte | string | "fcm" or "apns" | Which path the token should be delivered through. If absent: a new device starts as fcm, an existing device keeps what it had. |
Optional profile fields
All of them are best-effort: a strange value never fails the registration, and an absent field never erases what is already stored. They exist so you can understand your base without asking the user for any new permission.
| Field | Type | Note |
|---|---|---|
device_os | string | Truncated at 64 bytes. |
fabricante | string | Manufacturer. Truncated at 64 bytes. |
sdk_versao | string | Used to know who can already receive newer features. |
timezone_id | string | This one does take the zone name. |
pais | string | Country. Truncated at 64 bytes. |
net_type | string | Network type. |
carrier | string | Mobile carrier. |
standby_bucket | string | The system’s battery-saving state. |
instalacao_id | string | Stable install identifier. See below — it is worth gold. |
rooted | boolean | — |
bateria_irrestrita | boolean | Battery unrestricted. |
sessoes | integer | Session counter. Never decreases: the server keeps the highest value ever seen. A negative value is treated as absent. |
notif_permissoes | integer | Notification permission bitmask. A negative value is treated as absent. |
Response
{ "success": true, "id": "01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6" }
The rule that breaks the first integration
On registration, an omitted field ERASES the stored value.
It applies to six fields: external_user_id, app_version, device_model,
tags, language and timezone. Registration stores exactly the snapshot you
sent — including the empty parts of it.
The classic accident: the app sends external_user_id at login, then registers
without it on the next launch. The link disappears, and the base “loses” its
users without a single error showing up.
| Field group | An absent field… |
|---|---|
external_user_id, app_version, device_model, tags, language, timezone | erases the stored value |
transporte and every profile field | preserves the stored value |
Two ways not to fall into this:
- on registration, always send the device’s full snapshot (that is what the SDK does);
- to change one thing only, use
PUT— there the rule is the opposite.
Registering revives a dead token
If the device had been marked invalid (the provider rejected its token) and it registers again with the same token, it becomes valid again and the invalidation reason is cleared. If you mirror our base on your side, re-read it periodically — otherwise you will keep a device marked dead forever.
instalacao_id: the end of ghost devices
Reinstalling the app produces a new token, and the old token keeps looking alive until some send fails on it. The result is an inflated base of unreachable devices that you count as if they were people.
By sending instalacao_id — a stable install identifier — the server knows
immediately that it is the same device and retires its previous tokens
during the registration itself, without waiting for a send to fail.
Test devices
An identifier starting with test: creates a sandbox device. A sandbox
device never enters a send audience — not even when its id is listed
explicitly — and never shows up in the export. That is why it comes back in
invalid_player_ids when you send: not a bug, that is the guard working.
2. Login, logout and partial updates
PUT https://api.pushmesh.io/api/v1/players/{player_id}
This is the person ↔ device binding route.
# login
curl -X PUT https://api.pushmesh.io/api/v1/players/01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6 \
-H 'Content-Type: application/json' \
-d '{"app_id":"01926f3a-4b2c-7d8e-9f01-23456789abcd","external_user_id":"user-4711"}'
# logout
curl -X PUT https://api.pushmesh.io/api/v1/players/01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6 \
-H 'Content-Type: application/json' \
-d '{"app_id":"01926f3a-4b2c-7d8e-9f01-23456789abcd","external_user_id":null}'
{ "success": true }
| Field | Type | Required | What it does |
|---|---|---|---|
app_id | UUID | yes | The device’s application. |
external_user_id | string, null or absent | no | Three states — see below. |
tags | JSON object | no | Up to 2 KB serialized. |
app_version | string | no | The legacy name game_version is accepted. |
transporte | string | no | "fcm" or "apns". |
The three states of external_user_id
| In the body | Meaning |
|---|---|
| field absent | leave the binding alone |
"user-4711" | login: set the binding |
explicit null | logout: clear the binding |
Logout only happens with a written null. Omitting the field logs nobody
out — and that is the second most common cause of “the user signed out and kept
receiving messages”.
Here, an absent field does NOT erase
On PUT, app_version, tags and transporte preserve the stored value when
they are not in the body. It is the opposite of registration: read the rule for
the verb you are using, not for the other one.
3. When the same device changes users
This is where integrations get hurt the most, so here is the whole recipe.
A device is a physical object; an external_user_id is a person. The device
outlives the person using it — and if you don’t tell the server about the
change, the new person receives the old person’s messages.
The recipe:
- On login, call
PUTwithexternal_user_idset to the person’s id. - On logout, call
PUTwithexternal_user_id: null. Explicitly. - On every app launch, when registering, send the
external_user_idagain if the person is still signed in. Registering without it erases the binding (section 1). - Do not invent one
external_user_idper device. It exists to join the devices of one person; one value per device joins nothing and still burns the quota.
The cap: at most 10 devices per external_user_id. It is enforced both
on registration and on PUT — but only when the binding actually changes.
Re-sending the value the device already has always passes, so an SDK that
re-asserts the login on every launch never hits the cap. On the 11th device the
answer is 400 máximo 10 devices por external_user_id.
4. Read a device
GET https://api.pushmesh.io/api/v1/players/{player_id}?app_id=…
From your server, with the app key. This is where your integration decides whether a send is worth spending on that device.
curl 'https://api.pushmesh.io/api/v1/players/01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6?app_id=01926f3a-4b2c-7d8e-9f01-23456789abcd' \
-H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
{
"id": "01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6",
"identifier": "DEVICE-PUSH-TOKEN",
"device_type": 1,
"notification_types": 1,
"invalid_identifier": false,
"external_user_id": "user-4711",
"app_version": "3.2.0",
"device_model": "Model X",
"tags": { "plan": "premium" },
"last_active": 1756300000,
"created_at": 1756200000,
"playtime": 0,
"session_count": 0,
"device_os": null,
"language": "pt",
"timezone": -10800
}
| Field | What it is |
|---|---|
identifier | The push token, as stored. |
notification_types | Subscription state — see the table below. |
invalid_identifier | true when the token is dead (the provider rejected it, or it was superseded). |
last_active | Last sign of life, in epoch seconds. |
created_at | First registration, in epoch seconds. |
tags | Returns {} when there are no tags, never null. |
playtime, session_count, device_os | Shape constants: always 0, 0 and null. They exist for format compatibility, they are not measurements. Do not build metrics on them. |
notification_types, without makeup
The field’s contract has four states:
| Device state | notification_types |
|---|---|
| valid and subscribed | 1 |
| unsubscribed or blocked | -2 |
| never granted permission | 0 |
| dead token | last known value, with invalid_identifier: true |
And here is the honest part: no route in this API writes the 0 and -2
states today. A registered device starts subscribed and stays that way; what
changes over its lifetime is invalid_identifier. If you want a single true
question for “can I reach this device?”, the answer is
invalid_identifier === false.
If your app knows the user turned notifications off at the OS level, that fact
reaches the server through the notif_permissoes profile field on
registration — not through notification_types.
5. Export the base
POST https://api.pushmesh.io/api/v1/players/csv_export?app_id=…
A snapshot of who is alive, as compressed CSV, so your integration can check the base before spending a send on it.
curl -X POST 'https://api.pushmesh.io/api/v1/players/csv_export?app_id=01926f3a-4b2c-7d8e-9f01-23456789abcd' \
-H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
{
"csv_file_url": "https://…/exports/01926f3a-…/players-20260827T143000Z-…csv.gz",
"pm_pronto_em_s_estimado": 30,
"pm_validade_h": 72
}
The response is immediate and the file is produced in the background. The
loop on your side is simple: try to download the URL; while the file is not
ready it answers 404, and it answers 200 once it is.
The file is a gzipped CSV with five columns:
id,external_user_id,notification_types,invalid_identifier,last_active
last_activeis the device’s last real sign of life (an app session or a delivery receipt), in epoch seconds — that is what you use to skip inactive devices;- sandbox devices are left out;
- commas and quotes inside a field are escaped in standard CSV form, so any off-the-shelf parser reads it without special handling.
Three things to know before using it in production:
- The URL is the credential. The file carries
external_user_id, which in most integrations identifies a person. What protects the file is that its name cannot be guessed. That URL in a log, a ticket or a chat is a leak. pm_validade_h: 72is a contract window. Download the file within it, store it on your side, and do not use the URL as permanent storage.- One export per second, per application. Over that, the answer is
429— and the guidance is to reuse the previous URL, which is still valid, instead of asking for another. This429does not carry aRetry-Afterheader; wait one second.
6. How a device leaves the base
There is no public route to delete a device, and the absence is deliberate: the device routes are public, and a public route that can delete is a public route that can empty your base.
What actually takes a device out of reach:
| Event | Effect |
|---|---|
| The provider rejects the token on a send | The device becomes invalid_identifier: true and drops out of every audience. |
The app is reinstalled (with instalacao_id) | The previous token is retired during the new token’s registration. |
| You delete devices from the panel | The device and its delivery history are removed together, in the same transaction. |
Worth knowing: deleting is not banning. A live device you deleted registers itself again the next time the app is used. To stop talking to someone, the right tool is the binding (logout) or your own audience rule — not deletion.
If what you need is to honour a data subject’s deletion request — and to know how long each layer keeps what — the full recipe is in Data, retention and privacy.
Errors
Every error comes back in the same envelope:
{
"errors": ["requisição inválida: tags deve ser um objeto JSON"],
"explain": {
"causa": "tags deve ser um objeto JSON",
"como_corrigir": "corrija o payload; retry cego não resolve",
"request_id": "01926f5a-0000-7000-8000-000000000000"
}
}
Every response — success or error — carries the X-Pm-Request-Id header. It is
the thread that links your call to our service records; quote it in support
requests.
The
errorsandexplaintexts are written in Brazilian Portuguese, with no language negotiation. Branch on the HTTP status code, never on the string. The status codes and theexplainfield names are the contract; the prose is for humans and can change.
| Status | When it happens on these routes |
|---|---|
400 | Invalid JSON; device_type missing or unsupported; identifier empty or over 194 bytes; tags that is not an object or exceeds 2 KB; language over 32 bytes; timezone that is not an integer or is outside ±86400; transporte other than fcm/apns; external_user_id over 128 bytes; unknown app_id; the 11th device on the same external_user_id; missing app_id in the query of the server-side routes. |
401 | Authorization header missing or in an unknown format; key unknown, revoked or expired; app_id in the query that is not the key’s app. |
403 | Application paused. |
404 | player_id that does not exist in this application. |
413 | Body over 16 KB. |
429 | More than 120 requests per minute from the same IP (with Retry-After: 60); more than 50,000 registrations per hour in the same application; more than one export per second. |
503 | Database unavailable; export storage not configured in this environment. |
Limits, in numbers
| Limit | Value | Where it applies |
|---|---|---|
| Request body | 16 KB | routes called by the device |
identifier | 194 bytes | registration |
external_user_id | 128 bytes | registration and login |
tags (serialized JSON) | 2 KB | registration and update |
language | 32 bytes | registration |
timezone | ±86,400 seconds | registration |
Devices per external_user_id | 10 | registration and login |
| Registrations per hour, per application | 50,000 | registration |
| Requests per minute, per IP | 120 | routes called by the device |
| Exports | 1 per second, per application | export |
The per-IP cap is counted per service instance and depends on the source IP forwarded by the edge. Treat it as a safety net against a runaway loop, not as a number to tune fine-grained retries against.