PushMesh
Sign in Request access
Open section navigation

Index

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.

ModeHow you receiveDefault
consulta (polling)you call GET /api/v1/notifications/{id}yes
webhookPushMesh calls your URL
grpcyou 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

EventBorn when
delivery.receivedthe device confirmed the notification arrived
delivery.clickedthe person tapped the notification
notification.completedthe 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

HeaderContents
Content-Typeapplication/json
X-PM-Eventothe event name
X-PM-Entrega-Idunique identifier for this attempt — your idempotency key
X-PM-Canalwebhook
X-PM-Assinaturasha256=<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 canal field 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.

BehaviourValue
Timeout per attempt10 seconds
Interval between attempts1 s → 10 s → 60 s → 5 min → 15 min (ceiling)
Attempts before giving up30 (roughly 24 h of knocking)
Consecutive failures that disable the destination20
Orderingserial 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:

  1. make the PUT with the new URL;
  2. store the returned secret;
  3. 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-Assinatura with 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.clicked and notification.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

StatusReason
400URL is not https://; unknown mode; modo: "webhook" with no registered destination
401key missing, invalid or revoked
403app paused
503dependency unavailable — always named