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.
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);
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 });
}
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 });
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)
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.
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:
X-Pushoney-Signature — Stripe-shape
t=<unix>,v1=<hex>X-Pushoney-Event — event name (also in body)X-Pushoney-Delivery — delivery id (idempotent retry key)Content-Type: application/jsonUser-Agent: pushoney-webhook-worker/1Retry 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].