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