PushMesh
Sign in Request access
Open section navigation

Index

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, never 201 — 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 same player_id.


The four routes, and who calls each

RouteCalled byCredential
POST /api/v1/playersthe app, on the devicenone
PUT /api/v1/players/{id}the app, on the devicenone
GET /api/v1/players/{id}your serverapp key
POST /api/v1/players/csv_exportyour serverapp 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_id in the body is checked against the database before any write — unknown returns 400, paused application returns 403;
  • 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

FieldTypeLimitWhat it does
app_idUUIDThe application. Must exist and be active.
device_typeinteger0, 1, 5, 7 or 8Platform: 0 = iOS, 1 = Android, 5 = Chrome/web, 7 = Safari, 8 = Firefox. Any other value is rejected by name.
identifierstring1 to 194 bytesThe 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

FieldTypeLimitWhat it does
external_user_idstring128 bytesYour own person identifier (account id, hashed email…). Lets you target by user instead of by device. Maximum 10 devices per value.
app_versionstringYour app version. The legacy name game_version is also accepted; if both arrive, app_version wins.
device_modelstringDevice model, as free text.
tagsJSON object2 KB serializedYour own key/value pairs. Must be an object — a list or a string is rejected.
languagestring32 bytesShort language code ("pt", "en").
timezoneinteger±86400Time zone offset in seconds (e.g. -10800). A zone name here is an error.
transportestring"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.

FieldTypeNote
device_osstringTruncated at 64 bytes.
fabricantestringManufacturer. Truncated at 64 bytes.
sdk_versaostringUsed to know who can already receive newer features.
timezone_idstringThis one does take the zone name.
paisstringCountry. Truncated at 64 bytes.
net_typestringNetwork type.
carrierstringMobile carrier.
standby_bucketstringThe system’s battery-saving state.
instalacao_idstringStable install identifier. See below — it is worth gold.
rootedboolean
bateria_irrestritabooleanBattery unrestricted.
sessoesintegerSession counter. Never decreases: the server keeps the highest value ever seen. A negative value is treated as absent.
notif_permissoesintegerNotification 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 groupAn absent field…
external_user_id, app_version, device_model, tags, language, timezoneerases the stored value
transporte and every profile fieldpreserves the stored value

Two ways not to fall into this:

  1. on registration, always send the device’s full snapshot (that is what the SDK does);
  2. 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 }
FieldTypeRequiredWhat it does
app_idUUIDyesThe device’s application.
external_user_idstring, null or absentnoThree states — see below.
tagsJSON objectnoUp to 2 KB serialized.
app_versionstringnoThe legacy name game_version is accepted.
transportestringno"fcm" or "apns".

The three states of external_user_id

In the bodyMeaning
field absentleave the binding alone
"user-4711"login: set the binding
explicit nulllogout: 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:

  1. On login, call PUT with external_user_id set to the person’s id.
  2. On logout, call PUT with external_user_id: null. Explicitly.
  3. On every app launch, when registering, send the external_user_id again if the person is still signed in. Registering without it erases the binding (section 1).
  4. Do not invent one external_user_id per 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
}
FieldWhat it is
identifierThe push token, as stored.
notification_typesSubscription state — see the table below.
invalid_identifiertrue when the token is dead (the provider rejected it, or it was superseded).
last_activeLast sign of life, in epoch seconds.
created_atFirst registration, in epoch seconds.
tagsReturns {} when there are no tags, never null.
playtime, session_count, device_osShape 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 statenotification_types
valid and subscribed1
unsubscribed or blocked-2
never granted permission0
dead tokenlast 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_active is 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:

  1. 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.
  2. pm_validade_h: 72 is a contract window. Download the file within it, store it on your side, and do not use the URL as permanent storage.
  3. 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. This 429 does not carry a Retry-After header; 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:

EventEffect
The provider rejects the token on a sendThe 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 panelThe 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 errors and explain texts are written in Brazilian Portuguese, with no language negotiation. Branch on the HTTP status code, never on the string. The status codes and the explain field names are the contract; the prose is for humans and can change.

StatusWhen it happens on these routes
400Invalid 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.
401Authorization header missing or in an unknown format; key unknown, revoked or expired; app_id in the query that is not the key’s app.
403Application paused.
404player_id that does not exist in this application.
413Body over 16 KB.
429More 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.
503Database unavailable; export storage not configured in this environment.

Limits, in numbers

LimitValueWhere it applies
Request body16 KBroutes called by the device
identifier194 bytesregistration
external_user_id128 bytesregistration and login
tags (serialized JSON)2 KBregistration and update
language32 bytesregistration
timezone±86,400 secondsregistration
Devices per external_user_id10registration and login
Registrations per hour, per application50,000registration
Requests per minute, per IP120routes called by the device
Exports1 per second, per applicationexport

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.