Webhooks
Receba confirmações de entrega e clique no seu servidor — eventos, formato do corpo, validação da assinatura HMAC em Node e Python, e a política de reentrega.
Webhooks
Depois que um push sai, existem duas formas de saber o que aconteceu: perguntar ou ser avisado. O webhook é a segunda — o PushMesh chama a sua URL quando o aparelho confirma que a mensagem chegou, quando a pessoa toca nela, e quando o disparo fecha.
Se você tem qualquer volume, é o caminho certo: em vez de milhares de consultas por minuto perguntando “e agora?”, você recebe o fato no instante em que ele acontece.
Antes de ligar: é um ou o outro
Consulta e webhook são mutuamente exclusivos, de propósito.
Enquanto o modo for webhook, as rotas GET /api/v1/notifications e
GET /api/v1/notifications/{id} respondem 403 explicando o motivo. Se você
tem uma rotina de consulta em laço rodando hoje, ela para de funcionar no
instante da virada — planeje a troca.
O que continua respondendo é GET /api/v1/notifications/stats, a leitura por
janela de tempo. E o painel continua mostrando tudo: a exclusividade é do canal
de integração, não da visão humana.
| Modo | Como você recebe | Padrão |
|---|---|---|
consulta | você chama GET /api/v1/notifications/{id} | sim |
webhook | o PushMesh chama a sua URL | |
grpc | você mantém um stream aberto |
Ligando em uma chamada
curl -X PUT https://api.pushmesh.io/api/v1/confirmacoes \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "Content-Type: application/json" \
-d '{
"modo": "webhook",
"url": "https://seu-servidor.exemplo/pushmesh"
}'
{
"ok": true,
"modo": "webhook",
"segredo": "pm_whsec_4f3a…64 caracteres hexadecimais…"
}
Guarde o segredo agora. Ele aparece uma única vez e não existe rota
que o leia de volta. Sem ele você não consegue validar assinatura nenhuma.
Regras conferidas na chamada:
- a URL precisa ser
https://. Confirmação de entrega em texto claro seria vazamento por desenho — outro esquema responde 400; - cadastrar ou trocar a URL gera um segredo novo e desativa o destino anterior. Não existe “mesma URL, mesmo segredo”: se você reenviar a URL, o segredo antigo morre;
modo: "webhook"exige um destino cadastrado. Sem ele, 400 — virar a chave sem destino deixaria o aplicativo sem confirmação nenhuma. Por isso os dois campos na mesma chamada funcionam: a URL é processada antes do modo.
Mandar só a url não liga o modo webhook. São duas coisas: a url
cadastra o destino, o modo liga o canal.
Para ver o estado atual:
curl https://api.pushmesh.io/api/v1/confirmacoes \
-H "Authorization: Basic $PUSHMESH_KEY"
{ "modo": "webhook", "fila_pendente": 0, "streams_ativos": 0 }
fila_pendente é o número de eventos que ainda serão tentados. Se ele cresce,
seu endpoint está recusando ou demorando.
Os eventos
| Evento | Quando nasce |
|---|---|
delivery.received | o aparelho confirmou que a notificação chegou |
delivery.clicked | a pessoa tocou na notificação |
notification.completed | o disparo fechou: não há mais entrega pendente |
Cadastrando pela API, o destino assina os três.
Esses eventos nascem na mesma transação do fato: ou o recibo é gravado e o aviso é enfileirado, ou nenhum dos dois. Não existe “aconteceu mas não avisou”.
Corpo de delivery.received e delivery.clicked
{
"evento": "delivery.received",
"app_id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40",
"notification_id": "0198f0a1-3c22-7a11-8ef0-1122334455aa",
"player_id": "0198e9d3-15ab-7c40-91ce-77aa00bb11cc",
"quando": "2026-08-27T15:03:41.219+00:00",
"canal": "webhook"
}
Corpo de notification.completed
{
"evento": "notification.completed",
"app_id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40",
"notification_id": "0198f0a1-3c22-7a11-8ef0-1122334455aa",
"successful": 12840,
"failed": 12,
"errored": 3,
"recipients": 12855,
"quando": "2026-08-27T15:09:02.004+00:00",
"canal": "webhook"
}
Lembrete que vale dinheiro: successful é “o provedor aceitou”, não “o
aparelho recebeu”. A prova de chegada é a contagem de delivery.received — é
exatamente essa diferença que o recibo existe para medir.
Cabeçalhos de cada entrega
| Cabeçalho | Conteúdo |
|---|---|
Content-Type | application/json |
X-PM-Evento | o nome do evento |
X-PM-Entrega-Id | identificador único desta tentativa — sua chave de idempotência |
X-PM-Canal | webhook |
X-PM-Assinatura | sha256=<hex do HMAC-SHA256 do corpo> |
Validando a assinatura
Sem validação, qualquer um que descubra a sua URL injeta confirmação falsa na sua base. São dez linhas de código — não pule.
A assinatura é HMAC-SHA256 do corpo bruto, com o seu segredo, em
hexadecimal, prefixado por sha256=.
A regra que mais quebra integração: valide sobre os bytes crus do corpo, exatamente como chegaram. Se o seu framework desserializa o JSON e você re-serializa para conferir, os bytes mudam e a assinatura nunca bate. O campo
canalé inserido no corpo antes da assinatura, então ele faz parte do que foi assinado.
Compare em tempo constante. Comparação com == vaza o segredo byte a byte
para quem tiver paciência.
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SEGREDO = process.env.PUSHMESH_WEBHOOK_SECRET;
// express.raw — NÃO use express.json() nesta rota:
// o corpo precisa chegar como veio, byte a byte.
app.post('/pushmesh', express.raw({ type: 'application/json' }), (req, res) => {
const recebida = req.get('X-PM-Assinatura') || '';
const esperada =
'sha256=' + crypto.createHmac('sha256', SEGREDO).update(req.body).digest('hex');
const a = Buffer.from(recebida);
const b = Buffer.from(esperada);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('assinatura inválida');
}
const entregaId = req.get('X-PM-Entrega-Id');
if (jaProcessado(entregaId)) return res.sendStatus(200); // reentrega: ignore
const evento = JSON.parse(req.body.toString('utf8'));
switch (evento.evento) {
case 'delivery.received':
marcarEntregue(evento.notification_id, evento.player_id, evento.quando);
break;
case 'delivery.clicked':
marcarClique(evento.notification_id, evento.player_id, evento.quando);
break;
case 'notification.completed':
fecharCampanha(evento.notification_id, evento);
break;
}
registrarProcessado(entregaId);
res.sendStatus(200); // 2xx = pode tirar da fila
});
app.listen(3000);
Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SEGREDO = os.environ["PUSHMESH_WEBHOOK_SECRET"].encode()
@app.post("/pushmesh")
def pushmesh():
# get_data() devolve os BYTES CRUS — é sobre eles que a assinatura vale.
corpo = request.get_data()
esperada = "sha256=" + hmac.new(SEGREDO, corpo, hashlib.sha256).hexdigest()
recebida = request.headers.get("X-PM-Assinatura", "")
if not hmac.compare_digest(esperada, recebida):
abort(401)
entrega_id = request.headers.get("X-PM-Entrega-Id")
if ja_processado(entrega_id):
return "", 200 # reentrega: ignore
evento = request.get_json()
if evento["evento"] == "delivery.received":
marcar_entregue(evento["notification_id"], evento["player_id"], evento["quando"])
elif evento["evento"] == "delivery.clicked":
marcar_clique(evento["notification_id"], evento["player_id"], evento["quando"])
elif evento["evento"] == "notification.completed":
fechar_campanha(evento["notification_id"], evento)
registrar_processado(entrega_id)
return "", 200 # 2xx = pode tirar da fila
Conferindo à mão
printf '%s' "$CORPO_BRUTO" | openssl dgst -sha256 -hmac "$PUSHMESH_WEBHOOK_SECRET"
O hexadecimal impresso é o que vem depois de sha256= no cabeçalho.
Política de reentrega
A entrega é at-least-once: o evento só sai da fila depois do 2xx do seu servidor. Timeout, 5xx, erro de DNS — tudo reagenda.
| Comportamento | Valor |
|---|---|
| Tempo limite de cada tentativa | 10 segundos |
| Intervalo entre tentativas | 1 s → 10 s → 60 s → 5 min → 15 min (teto) |
| Tentativas até desistir | 30 (aproximadamente 24 h insistindo) |
| Falhas seguidas que desligam o destino | 20 |
| Ordem | em série por destino: o recebido chega antes do clicado |
Três consequências práticas:
1. Seu endpoint precisa ser idempotente. O mesmo evento pode chegar mais de
uma vez — é o preço de não perder nenhum. Deduplique por X-PM-Entrega-Id.
2. Responda 2xx rápido. Se o seu processamento demora mais de 10 segundos, a tentativa é considerada falha e o evento volta. Aceite, enfileire do seu lado e responda — depois processe.
3. O disjuntor existe para o seu bem. Vinte falhas seguidas do mesmo destino desligam o destino: os eventos ficam esperando na fila em vez de serem queimados contra um servidor que não responde. Consertou o endpoint, você religa pelo painel e a fila drena.
Um endpoint fora do ar por mais de ~24 horas perde eventos por desistência —
eles ficam registrados como desistência, auditáveis, nunca apagados em
silêncio. Se a sua manutenção for longa, a leitura por janela de tempo
(GET /api/v1/notifications/stats) reconstrói os números do período.
Trocando o segredo ou a URL
Reenviar a url gera um segredo novo e desativa o destino anterior. A ordem
segura é:
- faça o
PUTcom a URL nova; - guarde o segredo devolvido;
- só então aponte o tráfego para o endpoint novo.
Se você trocar a URL e esquecer de atualizar o segredo do lado de lá, todas as entregas passarão a ser rejeitadas como falsas — e a fila vai encher até o disjuntor desligar o destino.
Checklist do endpoint
- Responde em
https:// - Lê o corpo bruto antes de qualquer desserialização
- Valida
X-PM-Assinaturacom comparação em tempo constante - Recusa com 401 quando a assinatura não bate
- Deduplica por
X-PM-Entrega-Id - Responde 2xx em menos de 10 segundos
- Guarda o segredo em cofre, nunca no repositório
- Trata
delivery.received,delivery.clickedenotification.completed
Voltando para a consulta
curl -X PUT https://api.pushmesh.io/api/v1/confirmacoes \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "Content-Type: application/json" \
-d '{ "modo": "consulta" }'
As rotas de consulta voltam a responder na hora. O destino do webhook continua
cadastrado — voltar para webhook depois não exige cadastrar de novo (nem gera
segredo novo).
Erros da configuração
| Status | Motivo |
|---|---|
| 400 | URL não é https://; modo desconhecido; modo: "webhook" sem destino cadastrado |
| 401 | chave ausente, inválida ou revogada |
| 403 | aplicativo pausado |
| 503 | dependência indisponível — sempre nomeada |