Enviar push
A rota de disparo — público, conteúdo, agendamento, prioridade, dados livres, idempotência, ensaio e todos os limites com números.
Enviar push
POST https://api.pushmesh.io/api/v1/notifications
Cria um disparo. A chamada grava a mensagem e a lista de entregas numa única transação e responde na hora; a entrega aos provedores acontece logo em seguida, fora do seu pedido.
O id devolvido é o mesmo identificador que viaja dentro do push e é a
chave para consultar o resultado e casar os recibos de entrega.
Em 30 segundos
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-lote-1' \
-d '{
"app_id": "01926f3a-4b2c-7d8e-9f01-23456789abcd",
"included_segments": ["Subscribed Users"],
"headings": { "pt": "Chegou o novo catálogo" },
"contents": { "pt": "Toque para ver as novidades da semana." },
"url": "meuapp://catalogo"
}'
{ "id": "01926f3b-1111-7222-8333-444455556666", "recipients": 12840 }
Autenticação é a chave do app em Authorization (Basic). O app_id do corpo
tem de ser o app da chave — se não for, a resposta é 401, nunca 404.
1. Escolher o público
Você manda exatamente um dos três alvos por chamada. Nenhum é erro; dois ou mais também.
| Alvo | Tipo | Máximo | Quando usar |
|---|---|---|---|
include_player_ids | array de UUID | 2.000 | Você já tem os ids dos aparelhos. |
include_external_user_ids | array de string | 2.000 | Você pensa em pessoas, não em aparelhos. Um usuário com 3 aparelhos gera 3 entregas. |
included_segments | array de nomes | 10 | Disparo amplo, sem lista. |
Segmentos disponíveis
| Nome | Quem entra |
|---|---|
Subscribed Users | Todo aparelho alcançável. |
Total Subscriptions | O mesmo que o anterior (nome alternativo aceito). |
Active Users | Vistos nos últimos 7 dias. |
Engaged Users | Deram recibo de entrega nos últimos 7 dias. Não é clique. |
Nome desconhecido responde 400 listando os aceitos. Nomes repetidos são
deduplicados, e a união de vários segmentos nunca entrega duas vezes ao mesmo
aparelho.
O que significa “alcançável”
Um aparelho entra no público quando o token está válido, ele está inscrito e não é aparelho de sandbox. Aparelho de teste nunca recebe disparo real — nem quando o id dele está explícito na lista.
Filtrar por plataforma
isIos, isAndroid e isAnyWeb funcionam por exclusão:
| Valor | Efeito |
|---|---|
| ausente | inclui |
false | exclui |
true | inclui (não restringe nada) |
Mandar
isIos: trueachando que restringiu manda para a base inteira. Para falar só com iOS, exclua os outros:"isAndroid": false, "isAnyWeb": false.
Quando parte da lista não existe
Ids em formato inválido, ids que não existem e usuários sem nenhum aparelho alcançável não derrubam a chamada. O disparo sai para quem sobrou e a resposta traz o que ficou de fora:
{
"id": "01926f3b-1111-7222-8333-444455556666",
"recipients": 1840,
"errors": {
"invalid_player_ids": ["nao-e-um-uuid", "01926f4c-0000-7000-8000-000000000000"],
"invalid_external_user_ids": ["usuario-sem-app"]
}
}
Repare: aqui
errorsé um objeto. Em outro caso de200— ninguém alcançável — ele é um array. Um desserializador de tipo fixo quebra na primeira semana; trateerrorscomo união.
2. O conteúdo
| Campo | Tipo | Obrigatório | Limite | O que faz |
|---|---|---|---|---|
contents | objeto {idioma: texto} | sim | não pode ser vazio | O corpo da mensagem. Todos os valores têm de ser string. |
headings | objeto {idioma: texto} | não | — | O título. |
subtitle | objeto {idioma: texto} | não | — | Subtítulo (aparece no iOS). |
big_picture | string | não | URL absoluta http(s) | Imagem grande. |
large_icon | string | não | URL absoluta http(s) | Ícone grande. |
small_icon | string | não | — | Nome do ícone pequeno do Android. |
android_channel_id | string | não | — | Canal de notificação criado pelo seu app. |
android_accent_color | string | não | exatamente #RRGGBB | Cor de destaque no Android. |
ios_sound / android_sound | string | não | — | Som da notificação. |
ios_attachments | objeto {nome: url} | não | HTTPS obrigatório | Anexos do iOS. |
content_available | booleano | não | padrão false | Acorda o app em segundo plano no iOS. |
mutable_content | booleano | não | padrão false | Ligado automaticamente quando há big_picture ou ios_attachments. |
name | string | não | — | Rótulo interno do disparo, para você se achar depois. |
external_id | string | não | — | Seu identificador. É ecoado na resposta — e vira chave de idempotência por padrão (seção 5). |
Três recusas que economizam uma tarde de depuração:
android_accent_colorsó aceita#RRGGBB. Se vier no formato ARGB sem o#, o erro diz exatamente isso — antes esse valor passava e o provedor rejeitava a base Android inteira.- Anexo de iOS exige
https://. O iPhone não baixa anexo porhttp, e o push chega sem a imagem, em silêncio. Preferimos recusar na porta. - Imagem precisa de URL absoluta. Quem baixa a mídia é o provedor de push, na hora do envio; caminho relativo não existe para ele.
Qual idioma a pessoa vê
O servidor escolhe o texto no momento do envio, e escolhe um só — ele não
sabe o idioma do aparelho nesta fase. A escolha segue en e, na falta dele, a
primeira chave em ordem alfabética; no caminho direto com a Apple, en, depois
pt, depois a primeira em ordem alfabética.
Os mapas completos de idioma continuam viajando dentro do push, então um SDK próprio pode localizar no aparelho. O SDK que publicamos exibe o texto já resolvido pelo servidor. Se o seu público é de um idioma só, mande um idioma só: cada idioma extra ocupa espaço do orçamento de payload (seção 4).
Dados livres e deep link
data é um objeto JSON seu, entregue junto com o push.
"data": { "pedido": "8123", "tela": "detalhe" }
No caminho do Android, os valores de data viajam como texto — um número vira a
representação textual dele. Prefira mandar strings e converter do seu lado.
Para o destino do toque existem três campos, lidos nesta ordem de precedência:
url, app_url, web_url. O primeiro não vazio vence, os outros são ignorados
(mandar mais de um não é erro). O destino chega ao aparelho como pm_url.
Chaves reservadas em data
Algumas chaves não podem vir de você, e a chamada é recusada com 422 quando
aparecem — nunca ignorada em silêncio:
| Chave | Por que é reservada |
|---|---|
prefixo pm_ | É onde o servidor escreve o rastro da plataforma, incluindo a prova que permite o recibo de entrega. Se você pudesse escrever aqui, apagaria a prova da sua própria entrega. Para deep link use url/app_url/web_url. |
prefixo pmx_ | Bloco de exibição montado pelo servidor (título, corpo, imagem, canal, som, cor e ícone já resolvidos). Escrever aqui contornaria as validações de mídia e de cor. |
from, message_type, prefixo gcm., prefixo google. | Reservadas pelo provedor de push: com elas, o envio inteiro é rejeitado. |
aps exata e prefixo aps. | No iOS, aps é o bloco de apresentação da Apple. A regra é cirúrgica: aps_meta passa. |
3. Entrega: quando, com que urgência, por quanto tempo
| Campo | Tipo | Padrão | Faixa | O que faz |
|---|---|---|---|---|
send_after | string | envia agora | até 28 dias à frente | Agenda o disparo. Aceita ISO-8601 com offset (2026-09-01T11:00:00-03:00) e a forma 2026-09-01 11:00:00 GMT-0300. Data no passado é aceita e envia na hora. |
ttl | inteiro (segundos) | 259200 (3 dias) | 0 a 2419200 (28 dias) | Por quanto tempo o provedor tenta entregar em aparelho desligado. |
priority | inteiro ou string | alta | 10/"transactional" ou 5/"marketing" | Faixa de despacho. Transacional passa na frente. |
collapse_id | string | — | 64 bytes | Mensagens com o mesmo valor se substituem na bandeja em vez de empilhar. |
A prioridade também aceita o cabeçalho X-Pm-Priority: transactional|marketing.
Se vier nos dois lugares, o corpo vence — não é erro.
4. Limites, em números
| Limite | Valor |
|---|---|
| Corpo da requisição | 256 KB |
| Payload final da mensagem | 3.891 bytes |
| Destinatários por chamada (lista) | 2.000 |
| Segmentos por chamada | 10 |
collapse_id | 64 bytes |
ttl | 28 dias |
send_after | 28 dias à frente |
| Disparos criados por segundo, por aplicativo | 6.000 |
| Validade da chave de idempotência | 30 dias |
| Franquia de usuários ativos no mês (plano gratuito) | vem da configuração de cobrança — hoje 1.000 |
O limite que não é técnico
Há um limite nesta rota que não é sobre bytes nem sobre velocidade: a conta no
plano gratuito entrega dentro de uma franquia de usuários ativos no mês, e acima
dela o envio é recusado inteiro com 422. Não é truncamento — nada sai, nem
para parte da lista, de propósito.
O número não é fixo no código: ele vive na configuração de cobrança, e a página de preços é a fonte da verdade. Hoje o valor publicado é de 1.000 usuários ativos por mês, gratuitos em qualquer plano. A medição é da organização, não de cada aplicativo — dois aplicativos do mesmo cliente dividem a mesma franquia.
Duas honestidades: o número é apurado periodicamente em segundo plano (o
explain traz medido_ha_s, a idade da medição), e falha de infraestrutura
nunca barra ninguém — sem banco ou com uma medição velha demais, a conferência
libera o envio. O contrato completo está em
Limites e em
Autenticação e erros.
O limite que realmente pega é o de 3.891 bytes
Ele não é o tamanho do seu JSON: é o tamanho do payload final, montado pelo servidor, que o provedor de push vai carregar. Três coisas engordam essa conta mais do que a intuição sugere:
- o texto viaja mais de uma vez — uma cópia já resolvida para exibição e os mapas completos de idioma para o SDK;
- cada idioma a mais em
contents/headings/subtitleé um bloco a mais; - o servidor ainda acrescenta 96 bytes de rastro no momento do envio (o identificador do disparo e a prova de recibo).
Por isso a validação acontece na porta, antes de tocar o banco. Se estourar, o erro diz quantos bytes são seus, quantos são do servidor e quantos bytes você precisa encurtar:
{
"errors": ["payload renderizado acima do limite: 4102 bytes (máximo 3891)"],
"explain": {
"bytes": 4102,
"limite": 3891,
"bytes_injetados_pelo_servidor": 96
}
}
A alternativa seria pior: a mensagem passaria aqui e morreria com erro do provedor no envio, sem nenhum sinal para você.
5. Idempotência
A rede vai falhar no meio de uma campanha em algum momento. A idempotência é o que faz o retry ser seguro.
A chave é resolvida nesta ordem: cabeçalho Idempotency-Key, campo
idempotency_key do corpo, campo external_id. Qualquer formato serve, e ela
vale por 30 dias.
| Situação | Resposta |
|---|---|
| Mesma chave, mesmo corpo, disparo já concluído | 200 com a resposta original, byte a byte, e o cabeçalho X-Idempotent-Replay: true. Nada é disparado de novo. |
| Mesma chave, corpo diferente | 422, com os dois hashes no explain para você comparar. |
| Mesma chave, chamada concorrente ainda em curso | Espera até 3 segundos pelo vencedor e devolve a mesma resposta; se estourar, 409 pedindo para reler o resultado. |
O corpo é comparado por uma forma canônica: chaves de objeto ordenadas, arrays na ordem em que vieram, números como vieram. Duas consequências práticas:
send_afternunca é reinterpretado."2026-09-01T11:00:00-03:00"e"2026-09-01 11:00:00 GMT-0300"são o mesmo instante e hashes diferentes. Repita o retry com a grafia idêntica.- O mesmo vale para
1e1.0em qualquer número.
Atenção ao
external_id. Ele vira chave de idempotência quando você não manda uma explícita. Reusar o mesmoexternal_idem campanhas diferentes dá422. Se você usaexternal_idcomo rótulo, mande também umIdempotency-Keypróprio.
6. Ensaio: contar sem enviar
Mande o cabeçalho X-Dry-Run: true.
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": {"pt":"teste"} }'
{ "id": "", "recipients": 9312, "dry_run": true }
O público é resolvido de verdade — o recipients é o número real. Nada é
gravado, nada é entregue e a chave de idempotência não é consumida. O ensaio
passa por toda a validação de corpo e de payload, então é a forma barata de
descobrir que a mensagem não cabe.
Uma ressalva honesta: o ensaio consome cota da classe de envio, porque a cobrança acontece antes do teste. Ele é barato para o banco, não para o teto.
7. As respostas
Sucesso é sempre 200. Existem quatro formas:
// normal
{ "id": "01926f3b-…", "recipients": 12840 }
// parcial — errors é OBJETO
{ "id": "01926f3b-…", "recipients": 1840,
"errors": { "invalid_player_ids": ["…"] } }
// ninguém alcançável — errors é ARRAY e o id vem VAZIO
{ "id": "", "recipients": 0,
"errors": ["All included players are not subscribed"] }
// ensaio
{ "id": "", "recipients": 9312, "dry_run": true }
id: ""não é erro. Significa “ninguém alcançável” ou “ensaio” — o campodry_rundistingue. Quem trata id vazio como falha e repete a campanha acaba mandando em dobro.
Cabeçalhos da resposta
| Cabeçalho | O que diz |
|---|---|
X-Pm-Request-Id | Identificador desta chamada. Vem em toda resposta, inclusive nos erros. |
X-Pm-Lane | 0 para transacional, 1 para marketing. |
X-Pm-Recipients-Exato | false quando o alvo foi segmento (o número é estimativa), true nos demais. |
X-Idempotent-Replay | true apenas quando a resposta é a repetição de uma anterior. |
8. Erros
| Código | Motivo |
|---|---|
400 | Forma do pedido: nenhum alvo ou mais de um; lista acima de 2.000; mais de 10 segmentos; segmento desconhecido; contents ausente, vazio ou com valor que não é string; data que não é objeto; ttl fora da faixa; collapse_id acima de 64 bytes; send_after ilegível; priority inválida; app_id ausente ou que não é UUID. Nada tocou o banco. |
401 | Chave ausente, inválida, revogada ou expirada; app_id que não é o app da chave. |
403 | Aplicativo pausado. |
409 | Outra chamada com a mesma chave de idempotência está processando agora. Releia o resultado em vez de repetir. |
413 | Corpo acima de 256 KB. |
422 | Payload final acima de 3.891 bytes; chave de idempotência reusada com corpo diferente; chave reservada em data; URL de mídia inválida; anexo de iOS em http; android_accent_color fora de #RRGGBB; send_after além de 28 dias. E também: conta no plano gratuito acima da franquia de usuários ativos do mês — nesse caso o envio é recusado inteiro, e o explain traz o teto (mau_gratis), o número atual (mau), o mês medido e o preço de sair do plano gratuito. Repetir não resolve; a correção é o plano. |
429 | Teto de 6.000 disparos por segundo neste aplicativo. Este 429 não traz Retry-After: espere um segundo. |
503 | Banco indisponível. Nada foi gravado — repita com a mesma Idempotency-Key. |
Campos que respondem 400 de propósito
Se você está migrando de outra plataforma, vai encontrar recusas onde antes havia silêncio. É intencional: um campo que muda quem recebe ou o que a pessoa vê nunca é aceito e ignorado.
| Campo | O que fazer |
|---|---|
filters, excluded_segments, delayed_option, delivery_time_of_day, throttle_rate_per_minute, buttons | Ainda não existem nesta fase. Mudam quem ou quando recebe. |
template_id | Modelos de mensagem ainda não existem: monte o conteúdo em contents/headings. |
custom_data | Use data — é o mesmo objeto livre, com os nomes reservados validados. |
existing_android_channel_id | Use android_channel_id. |
android_visibility, android_led_color, android_group, ios_category | Ainda não são configuráveis por disparo. |
include_aliases, include_subscription_ids | São de um modelo de usuário diferente. Use include_player_ids, include_external_user_ids ou included_segments. |
web_buttons é a única exceção: aceito e ignorado, porque integrações legadas
mandam uma lista vazia por hábito.
Todo erro sai no mesmo envelope, com causa, como_corrigir e o
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"
}
}
Ramifique pelo código HTTP e pelos campos estruturados. Os textos são prosa para humano e podem mudar.
9. Uma campanha real, inteira
Promoção agendada, com imagem, deep link, dado próprio, prioridade de marketing, janela de entrega de 12 horas e retry seguro.
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: catalogo-primavera-2026-lote-3' \
-d '{
"app_id": "01926f3a-4b2c-7d8e-9f01-23456789abcd",
"name": "catálogo primavera — lote 3",
"included_segments": ["Active Users"],
"isAnyWeb": false,
"headings": { "pt": "Catálogo de primavera no ar" },
"contents": { "pt": "Peças novas com 20% até domingo. Toque para ver." },
"big_picture": "https://cdn.seudominio.com/campanhas/primavera.jpg",
"android_channel_id": "promocoes",
"android_accent_color": "#FF9900",
"url": "meuapp://catalogo/primavera",
"data": { "campanha": "primavera-2026", "lote": "3" },
"send_after": "2026-09-01T09:00:00-03:00",
"ttl": 43200,
"priority": "marketing",
"collapse_id": "catalogo-primavera"
}'
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 }
O que cada escolha comprou:
Active UserscomisAnyWeb: false— quem abriu o app nos últimos 7 dias, só em celular;collapse_id— se você mandar o lote 4 amanhã, ele substitui este na bandeja em vez de empilhar;ttl: 43200— a promoção acaba domingo; entregar na segunda seria pior do que não entregar;Idempotency-Key— se a conexão cair no meio da resposta, o retry devolve o mesmo disparo em vez de mandar 48 mil pushes de novo;priority: "marketing"— deixa a faixa transacional livre para o que é urgente.
10. E depois?
O id da resposta é a chave do que vem em seguida:
- Consultar o resultado de um disparo, incluindo quantos aparelhos provaram que receberam: veja Recibos de entrega.
- Ser avisado em vez de perguntar: você pode escolher receber os eventos por webhook assinado ou por stream, em vez de consultar.
- Histórico:
GET /api/v1/notifications?app_id=…lista os disparos, do mais recente para o mais antigo, com os mesmos campos da consulta individual.limittem padrão e teto de 50 — um valor maior é reduzido em silêncio, não vira erro.