PushMesh
Sign in Request access
Open section navigation

Index

Delivery receipts

The difference between "the provider accepted it" and "it reached the device" — how we prove the second one cryptographically, what that proof guarantees, and what it does not.

Delivery receipts

Almost every push platform answers a single question: did the provider accept the message? That is useful information, and it is far less than it sounds.

“Accepted” means Google’s or Apple’s service received the request and took on the job of trying to deliver it. Between that moment and the phone screen there is still: a device that is switched off, a device with no network for days, an app uninstalled while its token stays alive in your base, an operating system killing processes to save battery, and the provider’s own queue dropping the message when its deadline expires. None of that comes back to you.

PushMesh answers the second question: how many devices provably received it? Not by estimate, and not on the app’s word: through a cryptographic proof that only exists inside that push, for that device.


The two numbers, side by side

FieldWhat it meansWhere it comes from
successfulThe provider accepted the message.The provider’s answer at send time.
recebidosThe device returned the proof that the notification arrived.The device itself, with the proof verified on the server.

And the ratio between them, already computed for you:

"pm_metricas": { "cobertura_recibo_pct": 87.4 }

If you build an SLA, a campaign report or a resend decision on successful, you are measuring the provider’s queue. The device’s number is recebidos.


How the proof works

At send time, for each device, the server computes a signature and places it inside the push payload:

proof = HMAC-SHA256(app-key, "<send-id>:<device-id>")  → 32 characters
  • The signing key is 32 random bytes, stored encrypted on the application record, and it never leaves the server.
  • The proof is per (send × device): one device’s proof is worthless for another device, or for another send.
  • The SDK only echoes what it received. It computes nothing, and it could not.
  • Verification on the server is constant-time, and the rejection always carries the same text — there is no oracle for guessing a proof by trial and error.

The whole cycle:

  1. You call the send route. The send identifier goes into the payload.
  2. As each push leaves, the server injects that device’s proof.
  3. The push reaches the device carrying both.
  4. The SDK returns both, exactly as they arrived.
  5. The server recomputes the signature and compares. If it matches, the receipt is counted.

That is why keys with the pm_ prefix are reserved inside your data: if you could write into them, you would erase the proof of your own delivery.


What this proves — and what it does not

This is the section that decides whether you can trust the number. It is deliberately blunt.

The receipt proves

  • that this push, from this send, reached the app’s code on that specific device;
  • that the confirmation came from whoever held the proof — that is, from the device that received the message, not from a client making numbers up.

The click event additionally proves that the person tapped the notification. A click also stamps the delivery when it had not been counted yet: nobody clicks something that never arrived.

The receipt does NOT prove

  • That the person saw it. Delivered to the tray is not read. Nobody in this market measures “saw it”; neither do we.
  • That a missing receipt means a missing delivery. A device that is off, offline, or has already removed the app returns nothing. No receipt is no confirmation — not proof of failure.
  • 100% coverage. It does not exist on a real base, and you should distrust anyone who promises it. A number below 100% is the honest portrait of your base.

The Android/iOS difference, spelled out

On Android, the message is handed to your app in every state (foreground, background and killed), so the SDK confirms receipt on arrival.

On iOS, the system is what displays the notification, and your app may not run at all when the push arrives. The receipt is sent when the app processes the message: in the foreground, when the person taps the notification, or later — the SDK’s offline queue re-sends whatever was left behind on the next launch.

The practical consequence, and you should walk in knowing it: measured coverage on iOS is structurally lower than on Android. That is not a measurement defect, it is what the platform allows us to observe today. Compare iOS coverage with iOS over time, not with the Android half of the same send.

One more, about successful

The contract has a “delivered” state separate from “sent”, and both add up into successful. Today no path in the service ever writes “delivered” — in practice, successful is provider acceptance, full stop. Proof of arrival lives exclusively in recebidos.


How to read the data

One send

GET https://api.pushmesh.io/api/v1/notifications/{id}?app_id=…

curl 'https://api.pushmesh.io/api/v1/notifications/01926f3b-1111-7222-8333-444455556666?app_id=01926f3a-4b2c-7d8e-9f01-23456789abcd' \
  -H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
{
  "id": "01926f3b-1111-7222-8333-444455556666",
  "successful": 48120,
  "failed": 74,
  "errored": 16,
  "remaining": 0,
  "converted": 3907,
  "recebidos": 42061,
  "queued_at": 1756290000,
  "send_after": null,
  "completed_at": 1756290480,
  "name": "spring catalogue — batch 3",
  "headings": { "en": "The spring catalogue is live" },
  "contents": { "en": "New pieces, 20% off until Sunday." },
  "included_segments": ["Active Users"],
  "include_player_ids": null,
  "include_external_user_ids": null,
  "platform_delivery_stats": {
    "android": { "successful": 31004, "failed": 51 },
    "ios": { "successful": 17116, "failed": 23 }
  },
  "pm_metricas": {
    "via": "rest",
    "cobertura_recibo_pct": 87.4,
    "ttfd_ms": null,
    "duracao_ms": 480000
  }
}
FieldWhat it is
successfulThe provider accepted. Not proof of arrival.
failedPermanent rejection (a dead token, for instance).
erroredThree attempts exhausted.
remainingStill processing. It must reach zero; until it does, the verdict is not final.
convertedClicks confirmed by devices. 0 means nobody clicked.
recebidosThe proof of arrival.
queued_at, send_after, completed_atEpoch seconds.
included_segmentsAlways an array — empty, never null, when the target was a list.
platform_delivery_statsProvider acceptance broken down by platform.
pm_metricas.cobertura_recibo_pctrecebidos ÷ successful, to one decimal place.
pm_metricas.duracao_msFrom queueing to completion.
pm_metricas.ttfd_msAlways null today. It is part of the shape, not a measurement.

Three behaviours worth knowing before you write the client:

  • Asking the same question again is free. Reads of the same send within one second are served from cache and do not count against the cap of 1,000 reads per second. What blows the cap is asking for different things too fast.
  • 404 also means “belongs to another application”. The route never confirms the existence of someone else’s send.
  • If you switched on a webhook or a stream, this route answers 403. It is one channel or the other, never both — see the last section.

On the free plan

recebidos comes back as null, along with a structured explanation:

"recebidos": null,
"recebidos_gate": {
  "plano_atual": "free",
  "habilita_em": "pro",
  "por_que": "confirmação de entrega real é recurso do plano PRO"
}

It is never an HTTP error. But beware of defensive reads: a ?? 0 turns “not available on your plan” into “zero deliveries”.

A time window

GET https://api.pushmesh.io/api/v1/notifications/stats?app_id=…&desde=…&ate=…

For closing a campaign, a daily report or a billing cycle without one call per send.

curl 'https://api.pushmesh.io/api/v1/notifications/stats?app_id=01926f3a-4b2c-7d8e-9f01-23456789abcd&desde=2026-08-01T00:00:00-03:00&ate=2026-09-01T00:00:00-03:00' \
  -H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
{
  "desde": "2026-08-01T03:00:00+00:00",
  "ate": "2026-09-01T03:00:00+00:00",
  "totais": {
    "disparos": 214,
    "enviados": 4120334,
    "falhas": 8871,
    "entregues": 3604221,
    "cliques": 288940
  },
  "por_dia": [
    { "dia": "2026-08-01", "enviados": 130221, "entregues": 114003, "cliques": 9140 }
  ]
}
  • desde and ate are both required, in RFC 3339 with an offset, and the window is half-open: it includes the start and excludes the end. ate must be after desde.
  • enviados is provider acceptance; entregues is the real receipt; cliques are confirmed clicks.
  • The numbers come from per-send counters that outlive the rotation of the detailed history — the window works for any period, old or recent.
  • The dia in por_dia is computed in a fixed UTC−03 zone, with no daylight saving. If your reporting closes in another zone, cut the window with desde/ate and use totais.
  • por_dia is best-effort: if that part of the query fails it comes back empty while totais stays correct. Don’t read an empty list as “no day had any sends” without checking the totals.

This route keeps answering even with a webhook or a stream switched on — it is the only read that survives the channel change.


The route the device calls

POST https://api.pushmesh.io/api/v1/receipts

You do not need to call this route: the SDK already does it for you, including the offline queue, local deduplication and the click event. It is documented because anyone writing their own SDK needs it — and because a delivery contract you cannot inspect is not a contract.

{
  "app_id": "01926f3a-4b2c-7d8e-9f01-23456789abcd",
  "notification_id": "01926f3b-1111-7222-8333-444455556666",
  "player_id": "01926f4c-7a1b-7c2d-8e3f-a1b2c3d4e5f6",
  "rcpt": "9f4c2ab1e0d7358a6b1c4f902e77a13d",
  "evento": "recebido"
}
FieldTypeRequiredWhat it is
app_idUUIDyesThe application.
notification_idUUIDyesThe send identifier, as it came in the push.
player_idUUIDyesThe device’s public id.
rcptstringyesThe proof, echoed exactly as received. Up to 64 characters.
eventostringno"recebido" (default) or "clique".

No credential: the proof is the credential. What protects the route is the signature itself, plus a cap of 120 requests per minute per IP (this is the only 429 in the API that carries Retry-After: 60) and a 16 KB body cap.

The responses

ResponseMeaningWhat the client does
{"ok": true}Counted now.Drop the item from the queue.
{"ok": true, "duplicado": true}Already counted (a re-send, or the provider delivered twice).Drop the item.
{"ok": true, "tardio": true}That day’s detailed history was already archived, but the receipt was recorded and the counter went up.Drop the item.

duplicado and tardio are different things, and treating one as the other is exactly how receipts die in silence: duplicado means “we already had it”, tardio means “we had nowhere to put it, so we put it elsewhere — and it counted”.

Idempotency is guaranteed by three independent layers, and the last one is the database: the same receipt sent ten times counts once.

The acceptance window is 90 days. It comfortably covers the maximum legitimate delay — a schedule of up to 28 days plus a delivery deadline of up to 28 days — because a click can come from a notification that sat in the tray for weeks.

Errors

StatusReason
400Invalid JSON, missing field, malformed UUID, rcpt empty or over 64 characters, evento other than recebido/clique, unknown app_id.
403Invalid proof (always the same text, on purpose), or application paused.
413Body over 16 KB.
429More than 120 requests per minute from the same IP, with Retry-After: 60.
503Database unavailable.

Never compute the proof on the client. The key never leaves the server, and the rejection does not distinguish “wrong proof” from “no such send” — that is on purpose.


What to do with the data

A receipt is only worth what you do with it. Four uses that show up fast:

  1. Separate “I didn’t send” from “it didn’t arrive”. High successful with low recebidos in one specific segment is not a campaign problem: it is an app version, a device brand or a battery setting.
  2. Measure clicks against reality. Click rate over recebidos measures the message; over enviados, it measures the message mixed with the health of your base. The first is actionable, the second moves on its own.
  3. Stop paying for ghosts. A device that returns no receipt for weeks while still being “accepted” by the provider is a natural cleanup candidate — cross it with the base export.
  4. Track coverage as a health signal. A sudden coverage drop on the same audience is almost always a new version of your app breaking reception — and it is exactly the kind of problem that, without receipts, you only discover through falling revenue.

Being notified instead of asking

If you would rather not poll, every receipt can become an event on your server: delivery.received and delivery.clicked are created in the same transaction as the fact — there is no “it happened but nobody was told”.

Delivery is at-least-once: deduplicate by the attempt identifier that comes in each delivery’s header. The signature is HMAC-SHA256 over the raw body, so verify it before re-serializing the JSON. The details are on the webhooks page.

One consequence that catches people out: switching the webhook on switches polling off. The send-read routes start answering 403 immediately — one channel or the other, never both, so the same data is not paid for twice. The time-window route keeps working.