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 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' })
  }

  const result = await processWebhookTransaction(async (tx) => {
    const inserted = await tx.insertInboxEventIfAbsent({
      eventId,
      eventType: String(payload.event_type),
      rawBody,
    })
    if (!inserted) return 'duplicate'

    await applyBusinessSideEffects(tx, payload)
    return 'processed'
  })

  return res.status(200).json({ ok: true, duplicate: result === 'duplicate' })
})

insertInboxEventIfAbsent must be backed by a unique database constraint on event_id, and it must run in the same transaction as the business effect. A duplicate is already durably handled, so acknowledge it with 2xx.

The same receiver handles both product families: branch on payload.event_type after signature verification, preserve camelCase keys inside Polyflight payload.data, and never trigger another wallet debit, payout, refund, or compensation from a retried callback. For *.pending, record an obligation, not confirmed cash; *.processed and *.compensated describe already-completed movements. feeMinor is the included purchase fee, whereas refund and compensation reverse the full stake.

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

begin durable transaction
  inserted = insert inbound event_id under unique constraint
  if not inserted:
    commit
    return 200 without applying effect
  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 200 or another 2xx for an already-committed duplicate
  • 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
  • treating a reconstructed dashboard envelope or pretty-printed JSON as the original signed bytes
  • returning 409 for duplicates (any non-2xx is retried, including 409)
Copyright © 2026