Webhooks
Receive delivery and click confirmations on your own server — events, body format, HMAC signature validation in Node and Python, and the redelivery policy.
Webhooks
Once a push goes out, there are two ways to learn what happened to it: ask or be told. Webhooks are the second — PushMesh calls your URL when a device confirms the message arrived, when someone taps it, and when the send closes out.
At any real volume this is the right path: instead of thousands of polls per minute asking “what now?”, you receive the fact the moment it happens.
Before you switch it on: it is one or the other
Polling and webhooks are mutually exclusive, by design.
While the mode is webhook, GET /api/v1/notifications and
GET /api/v1/notifications/{id} return 403 explaining why. If you have a
polling loop running today, it stops working the instant you switch — plan
the cutover.
What keeps answering is GET /api/v1/notifications/stats, the time-window
read. And the dashboard keeps showing everything: the exclusivity applies to
the integration channel, not to the human view.
| Mode | How you receive | Default |
|---|---|---|
consulta (polling) | you call GET /api/v1/notifications/{id} | yes |
webhook | PushMesh calls your URL | |
grpc | you hold a stream open |
Switching it on in one call
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://your-server.example/pushmesh"
}'
{
"ok": true,
"modo": "webhook",
"segredo": "pm_whsec_4f3a…64 hexadecimal characters…"
}
Store segredo now. It is shown once and no route reads it back.
Without it you cannot validate a single signature.
Rules enforced on the call:
- the URL must be
https://. Delivery confirmations in the clear would be a leak by design — any other scheme returns 400; - registering or changing the URL mints a new secret and deactivates the previous destination. There is no “same URL, same secret”: resend the URL and the old secret dies;
modo: "webhook"requires a registered destination. Without one, 400 — flipping the switch with no destination would leave the app with no confirmations at all. That is why both fields in the same call work: the URL is processed before the mode.
Sending only the url does not turn on webhook mode. They are two separate
things: url registers the destination, modo switches the channel.
To see the current state:
curl https://api.pushmesh.io/api/v1/confirmacoes \
-H "Authorization: Basic $PUSHMESH_KEY"
{ "modo": "webhook", "fila_pendente": 0, "streams_ativos": 0 }
fila_pendente is the number of events still to be attempted. If it grows,
your endpoint is refusing or stalling.
The events
| Event | Born when |
|---|---|
delivery.received | the device confirmed the notification arrived |
delivery.clicked | the person tapped the notification |
notification.completed | the send closed: nothing left pending |
Registering through the API subscribes the destination to all three.
These events are born in the same transaction as the fact: either the receipt is recorded and the notice is queued, or neither happens. There is no “it happened but nobody was told”.
Body of delivery.received and 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"
}
Body of 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"
}
A reminder worth money: successful means “the provider accepted it”, not
“the device received it”. Proof of arrival is the count of delivery.received
events — that difference is exactly what receipts exist to measure.
Headers on every delivery
| Header | Contents |
|---|---|
Content-Type | application/json |
X-PM-Evento | the event name |
X-PM-Entrega-Id | unique identifier for this attempt — your idempotency key |
X-PM-Canal | webhook |
X-PM-Assinatura | sha256=<hex of the body's HMAC-SHA256> |
Validating the signature
Without validation, anyone who discovers your URL can inject false confirmations into your database. It is ten lines of code — do not skip it.
The signature is HMAC-SHA256 of the raw body, keyed with your secret,
hex-encoded, prefixed with sha256=.
The rule that breaks most integrations: validate over the raw bytes of the body, exactly as they arrived. If your framework deserialises the JSON and you re-serialise it to check, the bytes change and the signature will never match. The
canalfield is inserted into the body before signing, so it is part of what was signed.
Compare in constant time. A == comparison leaks the secret byte by byte to
anyone patient enough.
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.PUSHMESH_WEBHOOK_SECRET;
// express.raw — do NOT use express.json() on this route:
// the body must arrive exactly as sent, byte for byte.
app.post('/pushmesh', express.raw({ type: 'application/json' }), (req, res) => {
const received = req.get('X-PM-Assinatura') || '';
const expected =
'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const deliveryId = req.get('X-PM-Entrega-Id');
if (alreadyProcessed(deliveryId)) return res.sendStatus(200); // redelivery: ignore
const event = JSON.parse(req.body.toString('utf8'));
switch (event.evento) {
case 'delivery.received':
markDelivered(event.notification_id, event.player_id, event.quando);
break;
case 'delivery.clicked':
markClicked(event.notification_id, event.player_id, event.quando);
break;
case 'notification.completed':
closeCampaign(event.notification_id, event);
break;
}
recordProcessed(deliveryId);
res.sendStatus(200); // 2xx = safe to drop from the queue
});
app.listen(3000);
Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["PUSHMESH_WEBHOOK_SECRET"].encode()
@app.post("/pushmesh")
def pushmesh():
# get_data() returns the RAW BYTES — the signature is over these.
body = request.get_data()
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
received = request.headers.get("X-PM-Assinatura", "")
if not hmac.compare_digest(expected, received):
abort(401)
delivery_id = request.headers.get("X-PM-Entrega-Id")
if already_processed(delivery_id):
return "", 200 # redelivery: ignore
event = request.get_json()
if event["evento"] == "delivery.received":
mark_delivered(event["notification_id"], event["player_id"], event["quando"])
elif event["evento"] == "delivery.clicked":
mark_clicked(event["notification_id"], event["player_id"], event["quando"])
elif event["evento"] == "notification.completed":
close_campaign(event["notification_id"], event)
record_processed(delivery_id)
return "", 200 # 2xx = safe to drop from the queue
Checking by hand
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$PUSHMESH_WEBHOOK_SECRET"
The hex it prints is what follows sha256= in the header.
Redelivery policy
Delivery is at-least-once: an event only leaves the queue after your server returns 2xx. Timeouts, 5xx, DNS failures — everything reschedules.
| Behaviour | Value |
|---|---|
| Timeout per attempt | 10 seconds |
| Interval between attempts | 1 s → 10 s → 60 s → 5 min → 15 min (ceiling) |
| Attempts before giving up | 30 (roughly 24 h of knocking) |
| Consecutive failures that disable the destination | 20 |
| Ordering | serial per destination: received arrives before clicked |
Three practical consequences:
1. Your endpoint must be idempotent. The same event can arrive more than
once — that is the price of never losing one. Deduplicate on
X-PM-Entrega-Id.
2. Return 2xx quickly. If your processing takes longer than 10 seconds, the attempt counts as a failure and the event comes back. Accept it, queue it on your side, respond — then process.
3. The circuit breaker is there for your benefit. Twenty consecutive failures from the same destination disable the destination: events wait in the queue instead of being burned against a server that is not answering. Once the endpoint is fixed, you re-enable it from the dashboard and the queue drains.
An endpoint down for more than ~24 hours loses events to give-up — they are
recorded as abandoned, auditable, never silently discarded. If your maintenance
window is long, the time-window read
(GET /api/v1/notifications/stats) reconstructs the period’s numbers.
Rotating the secret or the URL
Resending the url mints a new secret and deactivates the previous
destination. The safe order is:
- make the
PUTwith the new URL; - store the returned secret;
- only then point traffic at the new endpoint.
If you change the URL and forget to update the secret on your side, every delivery will be rejected as forged — and the queue will fill until the circuit breaker disables the destination.
Endpoint checklist
- Served over
https:// - Reads the raw body before any deserialisation
- Validates
X-PM-Assinaturawith a constant-time comparison - Returns 401 when the signature does not match
- Deduplicates on
X-PM-Entrega-Id - Returns 2xx in under 10 seconds
- Keeps the secret in a vault, never in the repository
- Handles
delivery.received,delivery.clickedandnotification.completed
Going back to polling
curl -X PUT https://api.pushmesh.io/api/v1/confirmacoes \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "Content-Type: application/json" \
-d '{ "modo": "consulta" }'
The polling routes start answering again immediately. The webhook destination
stays registered — switching back to webhook later does not require
re-registering it (and does not mint a new secret).
Configuration errors
| Status | Reason |
|---|---|
| 400 | URL is not https://; unknown mode; modo: "webhook" with no registered destination |
| 401 | key missing, invalid or revoked |
| 403 | app paused |
| 503 | dependency unavailable — always named |