PushMesh
Sign in Request access
Open section navigation

Index

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.

TargetTypeMaximumWhen to use
include_player_idsarray of UUID2,000You already have the device ids.
include_external_user_idsarray of string2,000You think in people, not devices. One user with 3 devices produces 3 deliveries.
included_segmentsarray of names10Broad send, no list.

Available segments

NameWho is in it
Subscribed UsersEvery reachable device.
Total SubscriptionsSame as above (accepted alternative name).
Active UsersSeen in the last 7 days.
Engaged UsersReturned 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:

ValueEffect
absentincludes
falseexcludes
trueincludes (restricts nothing)

Sending isIos: true thinking 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 errors is an object. In another 200 case — nobody reachable — it is an array. A fixed-type deserializer breaks in the first week; treat errors as a union.


2. The content

FieldTypeRequiredLimitWhat it does
contentsobject {lang: text}yescannot be emptyThe message body. Every value must be a string.
headingsobject {lang: text}noThe title.
subtitleobject {lang: text}noSubtitle (shows on iOS).
big_picturestringnoabsolute http(s) URLLarge image.
large_iconstringnoabsolute http(s) URLLarge icon.
small_iconstringnoAndroid small-icon name.
android_channel_idstringnoA notification channel created by your app.
android_accent_colorstringnoexactly #RRGGBBAccent colour on Android.
ios_sound / android_soundstringnoNotification sound.
ios_attachmentsobject {name: url}noHTTPS requirediOS attachments.
content_availablebooleannodefault falseWakes the app in the background on iOS.
mutable_contentbooleannodefault falseTurned on automatically when big_picture or ios_attachments is present.
namestringnoInternal label for the send, so you can find it later.
external_idstringnoYour 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_color only 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 over http, 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).

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:

KeyWhy it is reserved
pm_ prefixThis 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_ prefixThe 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. prefixReserved by the push provider: with them, the entire send is rejected.
exact aps and aps. prefixOn iOS, aps is Apple’s presentation block. The rule is surgical: aps_meta passes.

3. Delivery: when, how urgent, for how long

FieldTypeDefaultRangeWhat it does
send_afterstringsends nowup to 28 days aheadSchedules 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.
ttlinteger (seconds)259200 (3 days)0 to 2419200 (28 days)How long the provider keeps trying to deliver to a device that is off.
priorityinteger or stringhigh10/"transactional" or 5/"marketing"Dispatch lane. Transactional goes first.
collapse_idstring64 bytesMessages 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

LimitValue
Request body256 KB
Final message payload3,891 bytes
Recipients per call (list)2,000
Segments per call10
collapse_id64 bytes
ttl28 days
send_after28 days ahead
Sends created per second, per application6,000
Idempotency key lifetime30 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:

  1. your text travels more than once — one already-resolved copy for display, and the complete language maps for the SDK;
  2. every extra language in contents/headings/subtitle is another block;
  3. 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.

SituationResponse
Same key, same body, send already completed200 with the original response, byte for byte, plus the X-Idempotent-Replay: true header. Nothing is sent again.
Same key, different body422, with both hashes in explain so you can diff them.
Same key, a concurrent call still in flightWaits 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_after is 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 1 versus 1.0 in any number.

Careful with external_id. It becomes the idempotency key when you don’t send an explicit one. Reusing the same external_id across different campaigns returns 422. If you use external_id as a label, send your own Idempotency-Key as 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 realrecipients 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” — the dry_run field tells them apart. Treating an empty id as a failure and repeating the campaign is how you send it twice.

Response headers

HeaderWhat it says
X-Pm-Request-IdIdentifier of this call. Present on every response, errors included.
X-Pm-Lane0 for transactional, 1 for marketing.
X-Pm-Recipients-Exatofalse when the target was a segment (the number is an estimate), true otherwise.
X-Idempotent-Replaytrue only when the response is a replay of a previous one.

8. Errors

StatusReason
400Request 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.
401Key missing, invalid, revoked or expired; app_id that is not the key’s app.
403Application paused.
409Another call with the same idempotency key is processing right now. Re-read the result instead of repeating.
413Body over 256 KB.
422Final 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.
429The 6,000 sends per second cap for this application. This 429 does not carry Retry-After: wait one second.
503Database 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.

FieldWhat to do
filters, excluded_segments, delayed_option, delivery_time_of_day, throttle_rate_per_minute, buttonsNot available at this stage. They change who or when.
template_idMessage templates don’t exist yet: build the content in contents/headings.
custom_dataUse data — the same free object, with reserved names validated.
existing_android_channel_idUse android_channel_id.
android_visibility, android_led_color, android_group, ios_categoryNot configurable per send yet.
include_aliases, include_subscription_idsThey 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 errors and explain texts 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 Users with isAnyWeb: 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. limit defaults to and caps at 50 — a larger value is silently reduced, not rejected.