OpenPoly logo
Webhooks

Receiver examples

Implementation examples for verifying and storing webhook deliveries.

Receiver examples

Examples below show safe minimum flow.

Node example

import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const app = express()
app.use('/webhooks/openpoly', express.raw({ type: 'application/json' }))

const processedEventIds = new Set<string>()
const secret = process.env.OPENPOLY_WEBHOOK_SECRET!
const maxSkewMs = 5 * 60_000

function sign(timestamp: string, rawBody: Buffer) {
  const payload = `${timestamp}.${rawBody.toString('utf8')}`
  const digest = createHmac('sha256', secret).update(payload).digest('hex')
  return `v1=${digest}`
}

app.post('/webhooks/openpoly', async (req, res) => {
  const eventId = String(req.header('x-polynion-event-id') || '')
  const timestamp = String(req.header('x-polynion-timestamp') || '')
  const signature = String(req.header('x-polynion-signature') || '')
  const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from('')

  if (!eventId || !timestamp || !signature) {
    return res.status(401).json({ error: 'MISSING_SIGNATURE_HEADERS' })
  }

  const parsedTs = Date.parse(timestamp)
  if (!Number.isFinite(parsedTs) || Math.abs(Date.now() - parsedTs) > maxSkewMs) {
    return res.status(401).json({ error: 'TIMESTAMP_SKEW_TOO_LARGE' })
  }

  const expected = sign(timestamp, rawBody)
  const expectedBuf = Buffer.from(expected)
  const gotBuf = Buffer.from(signature)
  if (expectedBuf.length !== gotBuf.length || !timingSafeEqual(expectedBuf, gotBuf)) {
    return res.status(401).json({ error: 'INVALID_SIGNATURE' })
  }

  const payload = JSON.parse(rawBody.toString('utf8'))
  if (payload.event_id !== eventId) {
    return res.status(400).json({ error: 'EVENT_ID_MISMATCH' })
  }

  if (processedEventIds.has(eventId)) {
    return res.status(409).json({ error: 'DUPLICATE_EVENT_ID' })
  }

  await persistIncomingEvent(payload)
  await applyBusinessSideEffects(payload)
  processedEventIds.add(eventId)

  return res.status(200).json({ ok: true })
})

Replace in-memory dedupe with DB table or durable cache.

Generic pseudocode

read raw_body
read event_id, timestamp, signature headers
if any missing -> 401

if timestamp older/newer than allowed skew -> 401

expected = hmac_sha256_hex(secret, "{timestamp}.{raw_body}")
if "v1={expected}" != signature using constant-time compare -> 401

payload = parse_json(raw_body)
if payload.event_id != header event_id -> 400

if event_id already processed -> 409

begin durable transaction
  store inbound event log
  apply business effect
  mark event_id processed
commit

return 200

Response behavior

  • return 200 or other 2xx after durable success
  • return 401 for missing or bad signature
  • return 409 for duplicate already-processed event
  • return 500 for transient internal failure -> OpenPoly retries

Common mistakes

  • parsing JSON before reading raw body
  • rebuilding JSON string before signature verification
  • deduping by aggregate_id instead of event_id
  • returning 200 before durable commit
  • deleting dedupe record too early
Copyright © 2026