PushMesh
Entrar Solicitar acesso
Abrir navegação das seções

Índice

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.

AlvoTipoMáximoQuando usar
include_player_idsarray de UUID2.000Você já tem os ids dos aparelhos.
include_external_user_idsarray de string2.000Você pensa em pessoas, não em aparelhos. Um usuário com 3 aparelhos gera 3 entregas.
included_segmentsarray de nomes10Disparo amplo, sem lista.

Segmentos disponíveis

NomeQuem entra
Subscribed UsersTodo aparelho alcançável.
Total SubscriptionsO mesmo que o anterior (nome alternativo aceito).
Active UsersVistos nos últimos 7 dias.
Engaged UsersDeram 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:

ValorEfeito
ausenteinclui
falseexclui
trueinclui (não restringe nada)

Mandar isIos: true achando 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 de 200 — ninguém alcançável — ele é um array. Um desserializador de tipo fixo quebra na primeira semana; trate errors como união.


2. O conteúdo

CampoTipoObrigatórioLimiteO que faz
contentsobjeto {idioma: texto}simnão pode ser vazioO corpo da mensagem. Todos os valores têm de ser string.
headingsobjeto {idioma: texto}nãoO título.
subtitleobjeto {idioma: texto}nãoSubtítulo (aparece no iOS).
big_picturestringnãoURL absoluta http(s)Imagem grande.
large_iconstringnãoURL absoluta http(s)Ícone grande.
small_iconstringnãoNome do ícone pequeno do Android.
android_channel_idstringnãoCanal de notificação criado pelo seu app.
android_accent_colorstringnãoexatamente #RRGGBBCor de destaque no Android.
ios_sound / android_soundstringnãoSom da notificação.
ios_attachmentsobjeto {nome: url}nãoHTTPS obrigatórioAnexos do iOS.
content_availablebooleanonãopadrão falseAcorda o app em segundo plano no iOS.
mutable_contentbooleanonãopadrão falseLigado automaticamente quando há big_picture ou ios_attachments.
namestringnãoRótulo interno do disparo, para você se achar depois.
external_idstringnãoSeu 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_color só 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 por http, 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).

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:

ChavePor 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

CampoTipoPadrãoFaixaO que faz
send_afterstringenvia agoraaté 28 dias à frenteAgenda 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.
ttlinteiro (segundos)259200 (3 dias)0 a 2419200 (28 dias)Por quanto tempo o provedor tenta entregar em aparelho desligado.
priorityinteiro ou stringalta10/"transactional" ou 5/"marketing"Faixa de despacho. Transacional passa na frente.
collapse_idstring64 bytesMensagens 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

LimiteValor
Corpo da requisição256 KB
Payload final da mensagem3.891 bytes
Destinatários por chamada (lista)2.000
Segmentos por chamada10
collapse_id64 bytes
ttl28 dias
send_after28 dias à frente
Disparos criados por segundo, por aplicativo6.000
Validade da chave de idempotência30 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:

  1. o texto viaja mais de uma vez — uma cópia já resolvida para exibição e os mapas completos de idioma para o SDK;
  2. cada idioma a mais em contents/headings/subtitle é um bloco a mais;
  3. 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çãoResposta
Mesma chave, mesmo corpo, disparo já concluído200 com a resposta original, byte a byte, e o cabeçalho X-Idempotent-Replay: true. Nada é disparado de novo.
Mesma chave, corpo diferente422, com os dois hashes no explain para você comparar.
Mesma chave, chamada concorrente ainda em cursoEspera 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_after nunca é 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 1 e 1.0 em qualquer número.

Atenção ao external_id. Ele vira chave de idempotência quando você não manda uma explícita. Reusar o mesmo external_id em campanhas diferentes dá 422. Se você usa external_id como rótulo, mande também um Idempotency-Key pró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 campo dry_run distingue. Quem trata id vazio como falha e repete a campanha acaba mandando em dobro.

Cabeçalhos da resposta

CabeçalhoO que diz
X-Pm-Request-IdIdentificador desta chamada. Vem em toda resposta, inclusive nos erros.
X-Pm-Lane0 para transacional, 1 para marketing.
X-Pm-Recipients-Exatofalse quando o alvo foi segmento (o número é estimativa), true nos demais.
X-Idempotent-Replaytrue apenas quando a resposta é a repetição de uma anterior.

8. Erros

CódigoMotivo
400Forma 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.
401Chave ausente, inválida, revogada ou expirada; app_id que não é o app da chave.
403Aplicativo pausado.
409Outra chamada com a mesma chave de idempotência está processando agora. Releia o resultado em vez de repetir.
413Corpo acima de 256 KB.
422Payload 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.
429Teto de 6.000 disparos por segundo neste aplicativo. Este 429 não traz Retry-After: espere um segundo.
503Banco 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.

CampoO que fazer
filters, excluded_segments, delayed_option, delivery_time_of_day, throttle_rate_per_minute, buttonsAinda não existem nesta fase. Mudam quem ou quando recebe.
template_idModelos de mensagem ainda não existem: monte o conteúdo em contents/headings.
custom_dataUse data — é o mesmo objeto livre, com os nomes reservados validados.
existing_android_channel_idUse android_channel_id.
android_visibility, android_led_color, android_group, ios_categoryAinda não são configuráveis por disparo.
include_aliases, include_subscription_idsSã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 Users com isAnyWeb: 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. limit tem padrão e teto de 50 — um valor maior é reduzido em silêncio, não vira erro.