Endorsement Stream docs
Signed postbacks
We POST each batch of changes to your HTTPS endpoint as JSON, as they publish. Enter the endpoint at checkout or in your ballotdata.net account, where the signing secret is shown.
The request
POST https://your-server.example/ballotdata
Content-Type: application/json
BallotData-Signature: t=1760620800,v1=5f2b…
{
"delivery_id": 42,
"sent_at": "2026-10-16T14:02:11.000Z",
"changes": [
{ "id": "88123", "type": "endorsement.created", "occurred_at": "…",
"endorsement": { "id": 880412, "rank": null, "source_url": "https://…", "published_on": "2026-10-15" },
"endorser": { "id": 77, "name": "…", "category": { "slug": "advocacy", "name": "Advocacy" }, "state": "CA" },
"contest": { "id": 9031, "title": "Proposition 5", "kind": "measure", "district": { … } },
"election": { "id": 12, "name": "November 3, 2026 General", "date": "2026-11-03", "state": "CA" },
"choice": { "id": 51001, "label": "Yes", "measure_answer": "yes" } }
]
}Answer with any 2xx status. Anything else (or no answer within 15 seconds) is retried with growing gaps of up to an hour, and the error is shown in your account. We don’t follow redirects. A change can arrive twice after a retry; use its id to drop repeats.
Checking the signature
Check every request’s BallotData-Signature header with your secret: it holds a timestamp t and v1, the hex HMAC-SHA256 of t, a dot, and the raw body. Refuse requests whose signature doesn’t match or whose timestamp is more than five minutes off.
import { createHmac, timingSafeEqual } from 'node:crypto';
// header: the BallotData-Signature request header, e.g. "t=1760620800,v1=5f2b…"
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
}