Authentication and errors
What to read before your first line of code — key format, rotation without downtime, the error envelope, the status table, the edge limits, and why the API never confirms what exists.
Authentication and errors
This is the page to read before writing your first line of integration code. It answers three questions: how you identify yourself, what the API returns when something goes wrong, and where the edges are.
The public API lives at https://api.pushmesh.io and has three forms of
identification. Mixing them up is the number one mistake newcomers make:
| Who calls | What it sends | Routes |
|---|---|---|
| Your server | Authorization: Basic pm_live_… | sending, reading results, device base, In-App, confirmations |
| The device (SDK or your own client) | nothing — the app_id travels in the body | device registration, receipts, In-App package, Firebase parameters |
| Whoever operates the platform | Authorization: Bearer <platform key> | application creation and command-line key rotation |
There is a fourth credential that is not part of the API: the dashboard
session at https://app.pushmesh.io. It authenticates people, lasts 7 days,
and never replaces the application key — which is never sent to a browser.
1. The application key
Format
pm_live_a1b2c3d4_KE7pQ…43characters
│ │ └─ secret: 43 alphanumeric characters
│ └───────────── key id: 8 characters [a-z0-9]
└────────────────────── prefix: pm_live_ or pm_test_
Validation is literal. Anything that deviates from the format never reaches
the database: it is a 401 at the door.
Where it goes
Authorization: Basic pm_live_a1b2c3d4_KE7pQ…
The header parser is deliberately forgiving, so it works with any HTTP client. These three forms are equivalent:
# 1. the raw key after Basic (the most common way)
curl -H "Authorization: Basic $PUSHMESH_KEY" https://api.pushmesh.io/api/v1/confirmacoes
# 2. Bearer is accepted too (case-insensitive)
curl -H "Authorization: Bearer $PUSHMESH_KEY" https://api.pushmesh.io/api/v1/confirmacoes
# 3. base64 of user:password — what curl's -u and classic HTTP libraries produce
curl -u "pushmesh:$PUSHMESH_KEY" https://api.pushmesh.io/api/v1/confirmacoes
In the third form the username before the colon is ignored; only the password matters.
What is stored
Only a cryptographic digest of the key. The key itself never reaches the database, the logs, or the cache — there is no route, screen, or support request that can show it to you again. Comparison is constant-time, by design.
pm_test_ is a label, not an environment
A key can be issued with the pm_test_ prefix, and it is useful for separating
environments in your own configuration. Being straight with you: the prefix
changes nothing on the server — resolution and permissions are identical to a
pm_live_ key. There is no parallel sandbox environment.
Real rehearsal has two other paths, and both of them are genuine:
- the
X-Dry-Run: trueheader on a send, which resolves the audience and delivers nothing and stores nothing; - device tokens starting with
test:, which register normally and stay out of every send.
The app_id must be the key’s own
Almost every authenticated route requires an app_id (in the query or the
body) in addition to the header. If it is not that key’s application, the
answer is 401 — not 404, and not 403. Section 7 explains why.
2. Rotating without downtime
Rotating issues a new key and keeps the previous one valid for 24 hours. It is an overlap window: no in-flight call breaks, and you do not need a maintenance window.
The rotation response carries the new key (once only) and the moment the previous one expires:
{
"id": "00000000-0000-0000-0000-000000000000",
"api_key": "pm_live_e5f6g7h8_new_43_character_secret",
"kid": "e5f6g7h8",
"anterior_expira_em": "2026-08-28T14:05:00Z"
}
Recommended sequence:
- rotate (in the dashboard, or through the platform route);
- store the new key in your secret manager;
- restart or update the services that use it, comfortably inside the 24 h;
- confirm nothing still uses the old key before the deadline.
Two honest notes about that deadline:
- The previous key keeps working for the whole window. Rotation is a no-downtime swap, not an immediate cut-off: if your key leaked and you need it invalidated now, rotating is not enough — talk to the platform.
- Credential resolution is cached for 60 seconds. In practice an already expired key may still pass for roughly one extra minute on instances that already had it in memory. It is the worst case, and worth knowing before you time a cut-off by the clock.
Past the window, the old key answers:
{
"errors": ["não autorizado"],
"explain": {
"causa": "chave inexistente, revogada ou expirada",
"como_corrigir": "confira a chave; se houve rotação, a anterior vale por 24h",
"request_id": "0198f0b2-6e31-7a4c-9f10-2c9a1d4e7b55"
}
}
3. The routes that take no key — and why
These run inside the app installed on the phone and carry no
Authorization header:
- device registration and update;
- delivery and click receipts;
- In-App package and events;
- public Firebase parameters.
That is not an oversight; it is the design. A published application is a file anyone can download and inspect — a key embedded in it is a published key. What protects these routes instead of a credential:
| Protection | What it does |
|---|---|
| Application validation | the app_id in the body is checked before any data access: unknown ⇒ 400, paused ⇒ 403. |
| Shape validation | every field is checked before touching the database; a malformed request never costs a query. |
| Per-origin cap | 120 requests per minute per origin address, with Retry-After: 60. |
| Cryptographic proof | receipts and In-App events only count with the proof the server itself injected into that send, verified in constant time. |
Rule of thumb: pm_live_ exists only on your server. If you need to read a
device from your backend, use the authenticated read route — never carry the
key into the application.
4. The platform key
Two routes — create application and rotate key — accept
Authorization: Bearer <platform key>. It belongs to whoever operates the
service, not to the customer: in normal use you create the application and
rotate the key from the dashboard, which does exactly the same thing (24-hour
window included).
Three details that save debugging time:
- the credential is checked before the body. Broken JSON with no credential
answers
401, not400— debugging by the error body sends you looking in the wrong place; - if the instance has no such key configured, the answer is a
503naming the missing configuration, and it comes before the401; - every rejected attempt is recorded storing at most the key id — never the key that was presented.
5. The error envelope
An error that only says “400 Bad Request” hands the work back to you. Here the error response is part of the product: it says what happened, what to do next, and carries the request identifier.
100% of 4xx/5xx responses use this shape:
{
"errors": ["requisição inválida: contents é obrigatório (objeto {lang: texto})"],
"explain": {
"causa": "contents é obrigatório (objeto {lang: texto})",
"como_corrigir": "corrija o payload; retry cego não resolve",
"request_id": "0198f0b2-6e31-7a4c-9f10-2c9a1d4e7b55",
"doc_url": "https://api.pushmesh.io/docs#erros"
}
}
| Field | What it is |
|---|---|
errors | array of strings. The field classic integrations already read — kept for compatibility. |
explain.causa | what actually happened, in one sentence. |
explain.como_corrigir | the next action. When retrying will not help, it says so. |
explain.request_id | this request’s identifier. Also sent in the X-Pm-Request-Id header, including on successful responses. |
explain.doc_url | documentation anchor. Present only when the instance has a public address configured. |
Some errors carry the numbers you were missing
explain is additive. An idempotency key reused with a different body, for
example, returns both digests so you can diff them:
{
"errors": ["idempotency_key reused with a different request body"],
"explain": {
"causa": "request_hash divergente",
"como_corrigir": "use uma Idempotency-Key nova para um conteúdo novo",
"hash_original": "3f2a…",
"hash_recebido": "9b71…",
"request_id": "0198f0b2-6e31-7a4c-9f10-2c9a1d4e7b55"
}
}
And an over-budget payload tells you how many bytes are yours, how many the server injects, and how many to trim:
{
"errors": ["payload renderizado acima do limite: 4210 bytes (máximo 3891)"],
"explain": {
"causa": "o payload final FCM/APNs tem 4210 bytes — 4114 da sua mensagem renderizada mais 96 que o servidor injeta no envio (pm_msg_id + pm_rcpt, o rastro que permite recibo de entrega e de clique); o orçamento é 3891 (3,8 KB)",
"como_corrigir": "encurte contents/headings/data/imagens em pelo menos 319 bytes — o limite é do provedor (4 KB), com margem",
"bytes": 4210,
"limite": 3891,
"bytes_injetados_pelo_servidor": 96
}
}
Two reading caveats
errorscan be an object — but only inside a200. On a partially resolved send it arrives as{"invalid_player_ids": [...]}, and the call is a success. If you deserialize it into a fixed type, accept thearray | objectunion.- The
errorsandexplaintexts are written in Portuguese, with no language negotiation. Branch your code on the HTTP status and on the structuredexplainfields — never on the sentence, which is prose for humans and may be improved at any time.
6. Status table
| Status | What it means here |
|---|---|
| 200 | success. Includes two cases that look like errors: nobody reachable (empty id, recipients: 0, errors array) and partial send (errors object). |
| 201 | application created. |
| 304 | the In-App package has not changed since the ETag you presented. |
| 400 | the request shape is wrong: missing required field, wrong type, value out of range, unsupported field. Retrying will not help. |
| 401 | a credential problem: missing, unknown format, non-existent, revoked, expired — or an app_id that is not the key’s. |
| 403 | valid credential, insufficient permission: paused application, In-App channel switched off, confirmation mode incompatible with the route, invalid receipt proof. |
| 404 | the resource does not exist within this key’s scope. |
| 409 | state conflict: another call with the same idempotency key is being processed right now; or an In-App campaign in a state that does not accept the operation. |
| 413 | body above the route’s limit. The cause names the limit. |
| 422 | valid shape, impossible content: idempotency key reused with a different body, payload above 3,891 bytes, reserved name in data, invalid In-App campaign — and an account on the free plan that went past its monthly active user allowance, in which case the whole send is refused (section 8). |
| 429 | a cap was exceeded (per origin on device routes; per application on the send, read and export classes). |
| 500 | internal failure. Retrying with the same idempotency key is safe by contract. |
| 503 | a configuration or infrastructure dependency is missing. The message names what is missing, without ever revealing a secret. |
7. Why the API never confirms what exists
This is the part that surprises people — and precisely the part that earns trust from anyone with a security background.
The API is not an existence oracle. If it answered 404 when the key
belongs to another application, anyone holding a valid key could sweep
identifiers and learn what exists on the platform purely from the difference
between 401 and 404. Therefore:
401is about the key. It includes the case “your key is fine, but you asked for anapp_idthat is not yours”. From that key’s point of view, that application is simply not reachable.403is about permission, with identity already established. A paused application, an In-App channel switched off, or the read route on an application that chose to receive confirmations by webhook — because confirmation is one channel or the other, never both.404is about content inside your own scope. Since every data access goes through a tenant scope before touching the database, another customer’s resource is indistinguishable from a non-existent one. It is the same404, on purpose.
The same logic governs the cryptographic receipt proofs: the invalid-proof
403 always has the same wording, never reveals the expected value, and is
evaluated before any existence lookup — otherwise the route would become an
oracle for forging proofs by trial and error.
{
"errors": ["proibido"],
"explain": {
"causa": "prova de recibo (rcpt) inválida",
"como_corrigir": "não construa o rcpt no cliente: ele vem pronto no payload do push (data.pm_rcpt) e deve ser ecoado como veio"
}
}
8. The edges
Body size
| Routes | Limit | What happens when exceeded |
|---|---|---|
| Device routes (registration, receipt, In-App events) | 16 KB | 413. If Content-Length already declares an excess, the answer comes back without reading the body. |
| Notification send | 256 KB | 413. The limit is larger because the recipient list travels here (2,000 identifiers take about 80 KB). |
The 413 uses the same envelope as every other error, with the limit named in
the cause — never a raw 413 from an intermediate proxy.
Requests
| Cap | Where it applies | When exceeded |
|---|---|---|
| 120 per minute, per origin | device routes | 429 with Retry-After: 60. Rejected requests also count toward the window. |
| 6,000 per second, per application | notification send | 429, charged before any data read. |
| 1,000 per second, per application | reading one push’s result | 429. Only database hits count: asking about the same push repeatedly is served from a 1-second cache and is free. |
| 1 per second, per application | device base export | 429. The previous file is still valid — download the URL you already received. |
| 50,000 device registrations per hour, per application | device registration | 429, on a one-hour window. |
| 10 devices per user identifier | device registration and login | a named 400. |
Honesty about
Retry-After: the per-application429text mentions that header, but only the per-origin429(device routes) actually emits it. The safe rule for your client: honourRetry-Afterwhen it is present; when it is absent, wait the value quoted inexplain.como_corrigir(one second on the per-application caps).
Message content
The final payload handed to the provider cannot exceed 3,891 bytes. That budget includes 96 bytes the server injects after your call — the push identifier and the receipt proof, which is exactly what makes real delivery measurable. The check runs at the door, before anything is stored, and the error tells you how many bytes to trim.
The commercial edge: the free plan allowance
This is the only edge on this page that is not technical — and the only one
that refuses the whole send. It deserves a paragraph because it catches out anyone
who treats 422 as “malformed request” and gives up.
The free plan delivers within a monthly active user allowance. Above it,
POST /api/v1/notifications answers 422 and nothing is sent — not even to
part of the list. Truncating would be worse: you would see “campaign sent” and
part of your base simply would not receive it, with no signal at all.
The refusal is written so you can act without opening a ticket. explain carries
the numbers (the strings arrive in Portuguese, exactly as printed):
{
"errors": ["plano Free atende até 1000 usuários ativos no mês — sua conta está com 1240"],
"explain": {
"causa": "o plano Free entrega dentro da franquia de 1000 usuários ativos no mês, e em 2026-08 a sua conta registrou 1240; acima da franquia o envio é recusado INTEIRO — entregar só para uma parte da base, escolhida por ordem de consulta, seria sumir com o resto em silêncio",
"como_corrigir": "mude para o Premium (USD 0.005 por usuário ativo acima da franquia, sem mensalidade) e o envio volta na hora — os 1000 primeiros usuários ativos continuam gratuitos nos dois planos",
"plano": "free",
"mau": 1240,
"mau_gratis": 1000,
"mes": "2026-08",
"moeda": "USD",
"preco_por_mau": "0.005",
"medido_ha_s": 12
}
}
In English, the message reads “the free plan delivers within an allowance of 1000 monthly active users, and in 2026-08 your account recorded 1240; above the allowance the send is refused ENTIRELY”, and the fix is “move to the paid plan and sending resumes immediately”.
explain field | What it is |
|---|---|
plano | the account’s current plan |
mau | monthly active users measured |
mau_gratis | the allowance |
mes | the month measured, as YYYY-MM |
moeda, preco_por_mau | the current price per active user above the allowance |
medido_ha_s | the age of the measurement, in seconds |
Four things worth knowing before you write the handler for this error:
- Retrying does not help. That is exactly why it is a
422and not a429: waiting does not change the number. The fix is changing plan. - The number is not real time. Counting scans the base, so it is recomputed
periodically in the background — at most once a minute — and
medido_ha_stells you how old the number you are arguing with is. - An account on a paid plan is never refused here. It is not part of the measurement, so no code path can block it.
- Infrastructure failure blocks nobody. With no database, or with a measurement that is too old (over 600 seconds), the check lets the send through. Erring on the side of delivering is a written decision.
The allowance value lives in the billing configuration and the pricing page is the source of truth; the numbers in the example above are illustrative. The summary of every cap is in Limits.
9. Retry or not
| Status | Retry the same call? |
|---|---|
| 400, 422 | No. Fix the request; explain names the field. On the free plan allowance 422, the fix is not in your JSON — it is in the plan. |
| 401, 403 | No. Fix the credential or the application configuration. |
| 404 | No. Check the identifier. |
| 409 | Yes, after waiting a few seconds — or read the push result instead. |
| 413 | No, not without shrinking the body or splitting the batch. |
| 429 | Yes, respecting the wait. |
| 500, 503 | Yes, with the same idempotency key — safe by contract. |
The minimum you need to know about idempotency right now: send an
Idempotency-Key on your pushes, store it, and resend the same key with the
same body on a network failure or a 5xx. The original response comes back
byte for byte (with the X-Idempotent-Replay: true header) for 30 days, and
the same key never fires two campaigns. The same key with a different body
is a 422, on purpose. The full contract is in
Send a push.
Keep the request_id of every failure. It is what links your call to the
service’s own trace, and it is the first thing support will ask for.
See also
| Topic | Page |
|---|---|
| from zero to your first push with a receipt | Getting started |
| every header accepted and emitted | Headers |
| the full table of caps and product limits | Limits |
| creating an application and rotating the key | Applications and keys |
| signed confirmations instead of polling | Webhooks |
| what is frozen by contract and what may change | Versioning |