← Docs

Webhook receiver recipes

Working starter code for verifying Pushoney webhook signatures in 4 popular frameworks. Copy a snippet, paste it into your backend, swap in your signing_secret, and you're receiving events.

The raw-body trap. Signature verification requires the exact bytes the server sent — not a re-serialized object. Most frameworks parse the JSON body automatically, which destroys those bytes. Each recipe below shows the framework-specific incantation to capture the raw body BEFORE parsing.
On this page

Node.js + Express

Express's express.json() body parser destroys raw bytes. Wire express.raw() on the webhook route specifically.

// npm install express
// (Optionally) npm install @push/sdk-js for verifyWebhookSignature
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SIGNING_SECRET = process.env.PUSHONEY_SIGNING_SECRET; // from POST /v1/webhooks
const app = express();

function verifyPushoney(rawBody, sigHeader) {
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => p.split('=', 2)),
  );
  const t = Number.parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5min window
  const expected = createHmac('sha256', SIGNING_SECRET)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex');
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

// IMPORTANT: express.raw must come BEFORE any json parser on this route.
app.post(
  '/pushoney-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.header('x-pushoney-signature') ?? '';
    const raw = req.body.toString('utf8');
    if (!verifyPushoney(raw, sig)) {
      return res.status(400).send('invalid signature');
    }
    const event = JSON.parse(raw);
    console.log('event:', event.event, 'data:', event.data);
    // Handle the event...
    res.status(200).send('ok');
  },
);

app.listen(3000);

Next.js (App Router)

Use request.text() in the route handler — it returns the raw body as a string before any JSON parsing.

// app/api/pushoney-webhook/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { NextResponse } from 'next/server';

const SIGNING_SECRET = process.env.PUSHONEY_SIGNING_SECRET!;

function verify(rawBody: string, sigHeader: string): boolean {
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => p.split('=', 2)),
  );
  const t = Number.parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', SIGNING_SECRET)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex');
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

export async function POST(req: Request) {
  const raw = await req.text(); // raw body BEFORE parsing
  const sig = req.headers.get('x-pushoney-signature') ?? '';
  if (!verify(raw, sig)) {
    return new NextResponse('invalid signature', { status: 400 });
  }
  const event = JSON.parse(raw);
  // Handle...
  return NextResponse.json({ ok: true });
}

Node.js + Fastify

Fastify's default JSON parser swallows raw bytes. Add a pre-validation hook that captures the raw body for the webhook route.

// npm install fastify
import Fastify from 'fastify';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SIGNING_SECRET = process.env.PUSHONEY_SIGNING_SECRET;
const app = Fastify();

// Capture the raw body before Fastify's JSON parser consumes it.
app.addContentTypeParser(
  'application/json',
  { parseAs: 'string' },
  (_req, body, done) => {
    try {
      const parsed = JSON.parse(body);
      // Fastify exposes both the parsed body AND the raw via a
      // request decorator. Stash the raw on a per-request bag.
      done(null, { __raw: body, ...parsed });
    } catch (err) {
      done(err, undefined);
    }
  },
);

function verify(rawBody, sigHeader) {
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => p.split('=', 2)),
  );
  const t = Number.parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', SIGNING_SECRET)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex');
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

app.post('/pushoney-webhook', async (req, reply) => {
  const sig = req.headers['x-pushoney-signature'] ?? '';
  const raw = req.body.__raw;
  if (!verify(raw, sig)) {
    return reply.code(400).send({ error: 'invalid signature' });
  }
  const event = req.body;
  // Handle...
  return { ok: true };
});

app.listen({ port: 3000 });

Python + Flask

Flask's request.get_data() returns raw bytes when called before request.get_json().

# pip install flask
import hmac, hashlib, time, json
from flask import Flask, request

SIGNING_SECRET = "your_signing_secret_from_POST_/v1/webhooks"
app = Flask(__name__)

def verify(raw_body: bytes, sig_header: str) -> bool:
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        f"{t}.{raw_body.decode('utf-8')}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

@app.post("/pushoney-webhook")
def webhook():
    raw = request.get_data()  # bytes — must be called first
    sig = request.headers.get("x-pushoney-signature", "")
    if not verify(raw, sig):
        return ("invalid signature", 400)
    event = json.loads(raw)
    print("event:", event["event"], "data:", event["data"])
    # Handle...
    return ("ok", 200)

if __name__ == "__main__":
    app.run(port=3000)

Testing locally with ngrok

Pushoney can't POST to localhost. Use ngrok (or Cloudflare Tunnel / Tailscale Funnel) to give your local server a public URL:

# Terminal 1: run your webhook receiver
node webhook-server.js     # listening on :3000

# Terminal 2: tunnel
ngrok http 3000
# → https://abc123.ngrok.io forwarded to localhost:3000

# Register the tunnel as your webhook URL.
curl -X POST https://<your-hub>.pushoney.com/v1/webhooks \
  -H "Authorization: Bearer pk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/pushoney-webhook",
    "events": ["click.recorded","conversion.recorded"]
  }'

# Trigger a test event (e.g. fire a click on /c/<trackingId>).
# Within ~1s your local console prints the verified event.

What event payloads look like

Every webhook POSTs the same envelope shape:

{
  "event": "click.recorded",
  "hub_id": "cmoq44b9t00009gzhehsixsa0",
  "occurred_at": "2026-05-10T18:23:11.482Z",
  "data": {
    "send_id": "cmsendabc123",
    "campaign_id": "cmcamp456",
    "subscriber_id": "cmsub789",
    "clicked_at": "2026-05-10T18:23:11.481Z"
  }
}

Headers on every request:

Retry policy: 5xx + 408/429 → retry at 1m, 5m, 30m, 2h, 24h (5 attempts). Other 4xx → permanent fail. 410 Gone → we auto-disable the webhook (you signalled the endpoint is permanently removed).

Inspect attempts at GET /v1/webhooks/<id>/deliveries; manually retry a failed one with POST /v1/webhooks/<id>/deliveries/<delivery_id>/retry.

Stuck? Email [email protected].