Documentation
1. Publish an event
curl -X POST https://hookpulse.2.28.42.222.sslip.io/api/v1/apps/APP_ID/events \
-H "Authorization: Bearer hp_…" \
-H "Content-Type: application/json" \
-d '{
"type": "invoice.paid",
"payload": { "id": "in_123", "amount": 4900 },
"dedupeKey": "invoice-123-paid"
}'It returns as soon as the event is recorded and queued. Pass a dedupeKey and a retry from your job runner will not send the event twice.
2. What your customer receives
POST /their/endpoint
Content-Type: application/json
HookPulse-Signature: t=1700000000,v1=<base64 hmac>
HookPulse-Delivery-Id: dlv_…
HookPulse-Timestamp: 1700000000
{"id":"evt_…","type":"invoice.paid","createdAt":"…","data":{…}}3. Verifying it
Give your customers this. The two mistakes everyone makes are comparing with === and ignoring the timestamp, and both are silent.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(headers, rawBody, secret, toleranceSeconds = 300) {
const header = headers['hookpulse-signature']
const deliveryId = headers['hookpulse-delivery-id']
const timestamp = Number(/t=(\d+)/.exec(header)?.[1])
// Replay protection. Without this a captured request is valid forever.
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
const expected = createHmac('sha256', secret)
.update(`${deliveryId}.${timestamp}.${rawBody}`)
.digest('base64')
return [...header.matchAll(/v1=([^,]+)/g)].some(([, presented]) => {
const a = Buffer.from(presented)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
})
}Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and every signature fails.
4. Rotating a secret
Rotating sets a new secret and keeps the old one valid for a window you choose. We sign with the new one immediately, so your customers are nudged to migrate, but a receiver still checking against the old secret keeps working until the window closes.
5. The delivery log
Mint a portal token in your backend — the same shape as a webhook signature, naming one of your customers — and call the portal API from your own settings page. Every query is scoped by the subscriber in that token, so one of your customers can never see another’s payloads.
GET https://hookpulse.2.28.42.222.sslip.io/api/portal/APP_ID/deliveries
POST https://hookpulse.2.28.42.222.sslip.io/api/portal/APP_ID/deliveries/DELIVERY_ID/retry
Authorization: Bearer <portal token>Honest limits
- The queue is a PostgreSQL table. That makes the delivery log and the queue impossible to disagree, and a restart lose nothing. It is not the fastest possible design, and at very high throughput a broker would win.
- The worker runs in the web process. Claiming is safe for several workers already, so moving it out is a deployment change rather than a rewrite — but today delivery load and request load share a process.
- We do not follow redirects. Following one would send a signed payload to a host you never named. A 3xx is treated as a failure.
- An endpoint that fails 20 times in a row is disabled. One dead URL should not keep consuming attempts on everyone else’s behalf.