Send a push
The send route — audience, content, scheduling, priority, custom data, idempotency, dry run, and every limit with real numbers.
Send a push
POST https://api.pushmesh.io/api/v1/notifications
Creates a send. The call writes the message and the whole delivery list in a single transaction and answers immediately; delivery to the providers happens right after, outside your request.
The id you get back is the same identifier that travels inside the push,
and it is the key for reading the result and matching delivery receipts.
In 30 seconds
curl -X POST https://api.pushmesh.io/api/v1/notifications \
-H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: promo-2026-08-27-batch-1' \
-d '{
"app_id": "01926f3a-4b2c-7d8e-9f01-23456789abcd",
"included_segments": ["Subscribed Users"],
"headings": { "en": "The new catalogue is out" },
"contents": { "en": "Tap to see this week'\''s arrivals." },
"url": "myapp://catalogue"
}'
{ "id": "01926f3b-1111-7222-8333-444455556666", "recipients": 12840 }
Authentication is your app key in the Authorization header (Basic). The
app_id in the body must be the key’s app — if it isn’t, the answer is 401,
never 404.
1. Choosing the audience
You send exactly one of the three targets per call. Zero is an error; two or more is an error too.
| Target | Type | Maximum | When to use |
|---|---|---|---|
include_player_ids | array of UUID | 2,000 | You already have the device ids. |
include_external_user_ids | array of string | 2,000 | You think in people, not devices. One user with 3 devices produces 3 deliveries. |
included_segments | array of names | 10 | Broad send, no list. |
Available segments
| Name | Who is in it |
|---|---|
Subscribed Users | Every reachable device. |
Total Subscriptions | Same as above (accepted alternative name). |
Active Users | Seen in the last 7 days. |
Engaged Users | Returned a delivery receipt in the last 7 days. Not a click. |
An unknown name answers 400 listing the accepted ones. Repeated names are
deduplicated, and the union of several segments never delivers twice to the same
device.
What “reachable” means
A device joins the audience when its token is valid, it is subscribed and it is not a sandbox device. A test device never receives a real send — not even when its id is spelled out in the list.
Filtering by platform
isIos, isAndroid and isAnyWeb work by exclusion:
| Value | Effect |
|---|---|
| absent | includes |
false | excludes |
true | includes (restricts nothing) |
Sending
isIos: truethinking you narrowed the audience sends to your whole base. To reach iOS only, exclude the rest:"isAndroid": false, "isAnyWeb": false.
When part of the list doesn’t exist
Malformed ids, ids that don’t exist, and users with no reachable device do not fail the call. The send goes out to whoever is left, and the response tells you what was dropped:
{
"id": "01926f3b-1111-7222-8333-444455556666",
"recipients": 1840,
"errors": {
"invalid_player_ids": ["not-a-uuid", "01926f4c-0000-7000-8000-000000000000"],
"invalid_external_user_ids": ["user-without-app"]
}
}
Note: here
errorsis an object. In another200case — nobody reachable — it is an array. A fixed-type deserializer breaks in the first week; treaterrorsas a union.
2. The content
| Field | Type | Required | Limit | What it does |
|---|---|---|---|---|
contents | object {lang: text} | yes | cannot be empty | The message body. Every value must be a string. |
headings | object {lang: text} | no | — | The title. |
subtitle | object {lang: text} | no | — | Subtitle (shows on iOS). |
big_picture | string | no | absolute http(s) URL | Large image. |
large_icon | string | no | absolute http(s) URL | Large icon. |
small_icon | string | no | — | Android small-icon name. |
android_channel_id | string | no | — | A notification channel created by your app. |
android_accent_color | string | no | exactly #RRGGBB | Accent colour on Android. |
ios_sound / android_sound | string | no | — | Notification sound. |
ios_attachments | object {name: url} | no | HTTPS required | iOS attachments. |
content_available | boolean | no | default false | Wakes the app in the background on iOS. |
mutable_content | boolean | no | default false | Turned on automatically when big_picture or ios_attachments is present. |
name | string | no | — | Internal label for the send, so you can find it later. |
external_id | string | no | — | Your own identifier. It is echoed in the response — and it becomes the idempotency key by default (section 5). |
Three rejections that save an afternoon of debugging:
android_accent_coloronly accepts#RRGGBB. If it arrives as ARGB without the#, the error says exactly that — that value used to pass and get the whole Android base rejected by the provider.- iOS attachments require
https://. The iPhone will not fetch an attachment overhttp, and the push arrives with no image, silently. We would rather refuse at the door. - Images need an absolute URL. The push provider is the one downloading the media at send time; a relative path does not exist for it.
Which language the person sees
The server picks the text at send time, and it picks one — it does not know
the device’s language at this stage. The order is en, then the first key in
alphabetical order; on the direct Apple path it is en, then pt, then the
first key alphabetically.
The complete language maps still travel inside the push, so a custom SDK can localize on the device. The SDK we publish displays the text the server already resolved. If your audience speaks one language, send one language: each extra language eats into the payload budget (section 4).
Custom data and deep links
data is a JSON object of your own, delivered along with the push.
"data": { "order": "8123", "screen": "detail" }
On the Android path, data values travel as text — a number becomes its string
form. Prefer sending strings and converting on your side.
For the tap destination there are three fields, read in this order of
precedence: url, app_url, web_url. The first non-empty one wins and the
others are ignored (sending more than one is not an error). The destination
reaches the device as pm_url.
Reserved keys in data
Some keys cannot come from you, and the call is rejected with 422 when they
appear — never silently ignored:
| Key | Why it is reserved |
|---|---|
pm_ prefix | This is where the server writes the platform’s trace, including the proof that makes delivery receipts possible. If you could write here, you would erase the proof of your own delivery. For deep links use url/app_url/web_url. |
pmx_ prefix | The display block the server builds (title, body, image, channel, sound, colour and icon, already resolved). Writing here would bypass the media and colour validation. |
from, message_type, gcm. prefix, google. prefix | Reserved by the push provider: with them, the entire send is rejected. |
exact aps and aps. prefix | On iOS, aps is Apple’s presentation block. The rule is surgical: aps_meta passes. |
3. Delivery: when, how urgent, for how long
| Field | Type | Default | Range | What it does |
|---|---|---|---|---|
send_after | string | sends now | up to 28 days ahead | Schedules the send. Accepts ISO-8601 with offset (2026-09-01T11:00:00-03:00) and the form 2026-09-01 11:00:00 GMT-0300. A past date is accepted and sends immediately. |
ttl | integer (seconds) | 259200 (3 days) | 0 to 2419200 (28 days) | How long the provider keeps trying to deliver to a device that is off. |
priority | integer or string | high | 10/"transactional" or 5/"marketing" | Dispatch lane. Transactional goes first. |
collapse_id | string | — | 64 bytes | Messages sharing a value replace each other in the tray instead of stacking. |
Priority also accepts the X-Pm-Priority: transactional|marketing header. If it
comes in both places, the body wins — it is not an error.
4. Limits, in numbers
| Limit | Value |
|---|---|
| Request body | 256 KB |
| Final message payload | 3,891 bytes |
| Recipients per call (list) | 2,000 |
| Segments per call | 10 |
collapse_id | 64 bytes |
ttl | 28 days |
send_after | 28 days ahead |
| Sends created per second, per application | 6,000 |
| Idempotency key lifetime | 30 days |
| Monthly active user allowance (free plan) | comes from billing configuration — 1,000 today |
The limit that is not technical
There is one limit on this route that is not about bytes or speed: an account
on the free plan delivers within a monthly active user allowance, and above it
the send is refused entirely with 422. It is not truncation — nothing goes
out, not even to part of the list, on purpose.
The number is not hard-coded: it lives in the billing configuration, and the pricing page is the source of truth. The published value today is 1,000 monthly active users, free on any plan. The measurement is per organisation, not per application — two applications of the same customer share one allowance.
Two honest notes: the number is computed periodically in the background
(explain carries medido_ha_s, the age of the measurement), and
infrastructure failure never blocks anyone — with no database, or with a
measurement that is too old, the check lets the send through. The full contract
is in Limits and in
Authentication and errors.
The limit that actually bites is 3,891 bytes
It is not the size of your JSON: it is the size of the final payload, built by the server, that the push provider will carry. Three things inflate that number more than intuition suggests:
- your text travels more than once — one already-resolved copy for display, and the complete language maps for the SDK;
- every extra language in
contents/headings/subtitleis another block; - the server still adds 96 bytes of trace at send time (the send identifier and the receipt proof).
That is why the check happens at the door, before touching the database. If you go over, the error tells you how many bytes are yours, how many are the server’s, and how many bytes you need to cut:
{
"errors": ["payload renderizado acima do limite: 4102 bytes (máximo 3891)"],
"explain": {
"bytes": 4102,
"limite": 3891,
"bytes_injetados_pelo_servidor": 96
}
}
The alternative would be worse: the message would pass here and die with a provider error at send time, with no signal reaching you.
5. Idempotency
At some point the network will fail in the middle of a campaign. Idempotency is what makes the retry safe.
The key is resolved in this order: the Idempotency-Key header, the
idempotency_key body field, the external_id field. Any format works, and it
lasts 30 days.
| Situation | Response |
|---|---|
| Same key, same body, send already completed | 200 with the original response, byte for byte, plus the X-Idempotent-Replay: true header. Nothing is sent again. |
| Same key, different body | 422, with both hashes in explain so you can diff them. |
| Same key, a concurrent call still in flight | Waits up to 3 seconds for the winner and returns the same response; if that budget runs out, 409 asking you to re-read the result. |
The body is compared through a canonical form: object keys sorted, arrays in the order they arrived, numbers as they arrived. Two practical consequences:
send_afteris never reinterpreted."2026-09-01T11:00:00-03:00"and"2026-09-01 11:00:00 GMT-0300"are the same instant and different hashes. Retry with the identical spelling.- The same applies to
1versus1.0in any number.
Careful with
external_id. It becomes the idempotency key when you don’t send an explicit one. Reusing the sameexternal_idacross different campaigns returns422. If you useexternal_idas a label, send your ownIdempotency-Keyas well.
6. Dry run: count without sending
Send the X-Dry-Run: true header.
curl -X POST https://api.pushmesh.io/api/v1/notifications \
-H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-H 'X-Dry-Run: true' \
-H 'Content-Type: application/json' \
-d '{ "app_id": "…", "included_segments": ["Active Users"], "contents": {"en":"test"} }'
{ "id": "", "recipients": 9312, "dry_run": true }
The audience is resolved for real — recipients is the true number. Nothing
is written, nothing is delivered, and the idempotency key is not consumed. A dry
run goes through the full body and payload validation, so it is the cheap way to
find out that your message doesn’t fit.
One honest caveat: a dry run does consume send quota, because the charge happens before the check. It is cheap for the database, not for the cap.
7. The responses
Success is always 200. There are four shapes:
// normal
{ "id": "01926f3b-…", "recipients": 12840 }
// partial — errors is an OBJECT
{ "id": "01926f3b-…", "recipients": 1840,
"errors": { "invalid_player_ids": ["…"] } }
// nobody reachable — errors is an ARRAY and the id is EMPTY
{ "id": "", "recipients": 0,
"errors": ["All included players are not subscribed"] }
// dry run
{ "id": "", "recipients": 9312, "dry_run": true }
id: ""is not an error. It means “nobody reachable” or “dry run” — thedry_runfield tells them apart. Treating an empty id as a failure and repeating the campaign is how you send it twice.
Response headers
| Header | What it says |
|---|---|
X-Pm-Request-Id | Identifier of this call. Present on every response, errors included. |
X-Pm-Lane | 0 for transactional, 1 for marketing. |
X-Pm-Recipients-Exato | false when the target was a segment (the number is an estimate), true otherwise. |
X-Idempotent-Replay | true only when the response is a replay of a previous one. |
8. Errors
| Status | Reason |
|---|---|
400 | Request shape: no target or more than one; list over 2,000; more than 10 segments; unknown segment; contents missing, empty or with a non-string value; data that is not an object; ttl out of range; collapse_id over 64 bytes; unreadable send_after; invalid priority; app_id missing or not a UUID. Nothing touched the database. |
401 | Key missing, invalid, revoked or expired; app_id that is not the key’s app. |
403 | Application paused. |
409 | Another call with the same idempotency key is processing right now. Re-read the result instead of repeating. |
413 | Body over 256 KB. |
422 | Final payload over 3,891 bytes; idempotency key reused with a different body; reserved key in data; invalid media URL; iOS attachment over http; android_accent_color outside #RRGGBB; send_after beyond 28 days. And also: an account on the free plan above its monthly active user allowance — in that case the send is refused entirely, and explain carries the cap (mau_gratis), the current number (mau), the month measured and the price of leaving the free plan. Retrying does not help; the fix is the plan. |
429 | The 6,000 sends per second cap for this application. This 429 does not carry Retry-After: wait one second. |
503 | Database unavailable. Nothing was written — retry with the same Idempotency-Key. |
Fields that answer 400 on purpose
If you are migrating from another platform, you will meet rejections where there used to be silence. That is deliberate: a field that changes who receives or what the person sees is never accepted and ignored.
| Field | What to do |
|---|---|
filters, excluded_segments, delayed_option, delivery_time_of_day, throttle_rate_per_minute, buttons | Not available at this stage. They change who or when. |
template_id | Message templates don’t exist yet: build the content in contents/headings. |
custom_data | Use data — the same free object, with reserved names validated. |
existing_android_channel_id | Use android_channel_id. |
android_visibility, android_led_color, android_group, ios_category | Not configurable per send yet. |
include_aliases, include_subscription_ids | They belong to a different user model. Use include_player_ids, include_external_user_ids or included_segments. |
web_buttons is the single exception: accepted and ignored, because legacy
integrations tend to send an empty list out of habit.
Every error comes back in the same envelope, with causa, como_corrigir and
the request_id:
{
"errors": ["requisição inválida: informe exatamente UM alvo: include_player_ids, include_external_user_ids ou included_segments"],
"explain": {
"causa": "informe exatamente UM alvo: include_player_ids, include_external_user_ids ou included_segments",
"como_corrigir": "corrija o payload; retry cego não resolve",
"request_id": "01926f5a-0000-7000-8000-000000000000"
}
}
The
errorsandexplaintexts are written in Brazilian Portuguese. Branch on the HTTP status code and on the structured fields, never on the string.
9. A real campaign, end to end
A scheduled promotion with an image, a deep link, custom data, marketing priority, a 12-hour delivery window and a safe retry.
curl -X POST https://api.pushmesh.io/api/v1/notifications \
-H 'Authorization: Basic pm_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: spring-catalogue-2026-batch-3' \
-d '{
"app_id": "01926f3a-4b2c-7d8e-9f01-23456789abcd",
"name": "spring catalogue — batch 3",
"included_segments": ["Active Users"],
"isAnyWeb": false,
"headings": { "en": "The spring catalogue is live" },
"contents": { "en": "New pieces, 20% off until Sunday. Tap to browse." },
"big_picture": "https://cdn.yourdomain.com/campaigns/spring.jpg",
"android_channel_id": "promotions",
"android_accent_color": "#FF9900",
"url": "myapp://catalogue/spring",
"data": { "campaign": "spring-2026", "batch": "3" },
"send_after": "2026-09-01T09:00:00-03:00",
"ttl": 43200,
"priority": "marketing",
"collapse_id": "spring-catalogue"
}'
HTTP/1.1 200 OK
X-Pm-Request-Id: 01926f5a-0000-7000-8000-000000000000
X-Pm-Lane: 1
X-Pm-Recipients-Exato: false
{ "id": "01926f3b-1111-7222-8333-444455556666", "recipients": 48210 }
What each choice bought:
Active UserswithisAnyWeb: false— people who opened the app in the last 7 days, phones only;collapse_id— if you send batch 4 tomorrow, it replaces this one in the tray instead of stacking;ttl: 43200— the promotion ends on Sunday; delivering on Monday would be worse than not delivering;Idempotency-Key— if the connection drops mid-response, the retry returns the same send instead of pushing 48 thousand people twice;priority: "marketing"— keeps the transactional lane free for what is urgent.
10. What comes next
The id in the response is the key to everything that follows:
- Read the result of a send, including how many devices proved they received it: see Delivery receipts.
- Be notified instead of asking: you can choose to receive the events over a signed webhook or a stream, instead of polling.
- History:
GET /api/v1/notifications?app_id=…lists your sends, newest first, with the same fields as the single-send read.limitdefaults to and caps at 50 — a larger value is silently reduced, not rejected.