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