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.ai 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.
Our client libraries check it for you, and parse the body:
import express from 'express';
import { constructPostback, PostbackSignatureError } from 'ballotdata';
const app = express();
// The raw body: the signature is over the bytes as sent
app.post('/ballotdata', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const postback = await constructPostback(req.body, req.get('BallotData-Signature'), process.env.BALLOTDATA_SIGNING_SECRET);
for (const change of postback.changes) await handle(change); // change.id drops repeats
res.sendStatus(204);
} catch (err) {
if (err instanceof PostbackSignatureError) return res.sendStatus(401);
throw err;
}
});Without them, in Node:
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'));
}