Webhooks

When you submit a batch job with options.callback_url (plans with webhooks), posty.ro POSTs a JSON notification to that URL once the job finishes. Verify the X-Posty-Signature header before trusting the payload.

batch.completed

The batch job finished. Download the results from results_url (kept for 30 days).

{
  "event": "batch.completed",
  "batch_id": "bat_550e8400e29b4123",
  "status": "done",
  "row_count": 1200,
  "processed": 1200,
  "results_url": "https://api.posty.ro/v1/addresses/batch/bat_550e8400e29b4123/results",
  "finished_at": "2026-10-14T12:00:00Z"
}
batch.failed

The batch job failed. error_message describes the cause.

{
  "event": "batch.failed",
  "batch_id": "bat_550e8400e29b4123",
  "status": "failed",
  "row_count": 1200,
  "processed": 310,
  "results_url": "https://api.posty.ro/v1/addresses/batch/bat_550e8400e29b4123/results",
  "finished_at": "2026-10-14T12:00:00Z",
  "error_message": "..."
}

Verifying the signature

Every notification carries the X-Posty-Signature header: t=<unix>,v1=<hex>. v1 is the HMAC-SHA256 of "<t>." followed by the raw request body. Recompute it, compare in constant time and reject stale timestamps (e.g. older than 5 minutes) to block replays. Answer with a 2xx; any other status counts as a failure.

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

// header = req.headers['x-posty-signature'], rawBody = the exact bytes received
function verifyPostySignature(header, rawBody, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
  const t = Number(parts.t)
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false
  const expected = createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest('hex')
  const got = Buffer.from(parts.v1 ?? '', 'hex')
  return got.length === 32 && timingSafeEqual(got, Buffer.from(expected, 'hex'))
}

© 2026 posty.ro