PushMesh
Sign in Request access
Open section navigation

Index

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 callsWhat it sendsRoutes
Your serverAuthorization: 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 bodydevice registration, receipts, In-App package, Firebase parameters
Whoever operates the platformAuthorization: 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: true header 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:

  1. rotate (in the dashboard, or through the platform route);
  2. store the new key in your secret manager;
  3. restart or update the services that use it, comfortably inside the 24 h;
  4. 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:

ProtectionWhat it does
Application validationthe app_id in the body is checked before any data access: unknown ⇒ 400, paused ⇒ 403.
Shape validationevery field is checked before touching the database; a malformed request never costs a query.
Per-origin cap120 requests per minute per origin address, with Retry-After: 60.
Cryptographic proofreceipts 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, not 400 — debugging by the error body sends you looking in the wrong place;
  • if the instance has no such key configured, the answer is a 503 naming the missing configuration, and it comes before the 401;
  • 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"
  }
}
FieldWhat it is
errorsarray of strings. The field classic integrations already read — kept for compatibility.
explain.causawhat actually happened, in one sentence.
explain.como_corrigirthe next action. When retrying will not help, it says so.
explain.request_idthis request’s identifier. Also sent in the X-Pm-Request-Id header, including on successful responses.
explain.doc_urldocumentation 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

  • errors can be an object — but only inside a 200. 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 the array | object union.
  • The errors and explain texts are written in Portuguese, with no language negotiation. Branch your code on the HTTP status and on the structured explain fields — never on the sentence, which is prose for humans and may be improved at any time.

6. Status table

StatusWhat it means here
200success. Includes two cases that look like errors: nobody reachable (empty id, recipients: 0, errors array) and partial send (errors object).
201application created.
304the In-App package has not changed since the ETag you presented.
400the request shape is wrong: missing required field, wrong type, value out of range, unsupported field. Retrying will not help.
401a credential problem: missing, unknown format, non-existent, revoked, expired — or an app_id that is not the key’s.
403valid credential, insufficient permission: paused application, In-App channel switched off, confirmation mode incompatible with the route, invalid receipt proof.
404the resource does not exist within this key’s scope.
409state 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.
413body above the route’s limit. The cause names the limit.
422valid 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).
429a cap was exceeded (per origin on device routes; per application on the send, read and export classes).
500internal failure. Retrying with the same idempotency key is safe by contract.
503a 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:

  • 401 is about the key. It includes the case “your key is fine, but you asked for an app_id that is not yours”. From that key’s point of view, that application is simply not reachable.
  • 403 is 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.
  • 404 is 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 same 404, 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

RoutesLimitWhat happens when exceeded
Device routes (registration, receipt, In-App events)16 KB413. If Content-Length already declares an excess, the answer comes back without reading the body.
Notification send256 KB413. 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

CapWhere it appliesWhen exceeded
120 per minute, per origindevice routes429 with Retry-After: 60. Rejected requests also count toward the window.
6,000 per second, per applicationnotification send429, charged before any data read.
1,000 per second, per applicationreading one push’s result429. Only database hits count: asking about the same push repeatedly is served from a 1-second cache and is free.
1 per second, per applicationdevice base export429. The previous file is still valid — download the URL you already received.
50,000 device registrations per hour, per applicationdevice registration429, on a one-hour window.
10 devices per user identifierdevice registration and logina named 400.

Honesty about Retry-After: the per-application 429 text mentions that header, but only the per-origin 429 (device routes) actually emits it. The safe rule for your client: honour Retry-After when it is present; when it is absent, wait the value quoted in explain.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 fieldWhat it is
planothe account’s current plan
maumonthly active users measured
mau_gratisthe allowance
mesthe month measured, as YYYY-MM
moeda, preco_por_mauthe current price per active user above the allowance
medido_ha_sthe 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 422 and not a 429: 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_s tells 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

StatusRetry the same call?
400, 422No. 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, 403No. Fix the credential or the application configuration.
404No. Check the identifier.
409Yes, after waiting a few seconds — or read the push result instead.
413No, not without shrinking the body or splitting the batch.
429Yes, respecting the wait.
500, 503Yes, 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

TopicPage
from zero to your first push with a receiptGetting started
every header accepted and emittedHeaders
the full table of caps and product limitsLimits
creating an application and rotating the keyApplications and keys
signed confirmations instead of pollingWebhooks
what is frozen by contract and what may changeVersioning