BetterJobs POSTs events to a URL you own. Two things send them: watches (a company domain or a saved search) and async searches created with webhook_url. Your endpoint verifies the signature, stores the event, and returns 2xx fast.
The event envelope
Section titled “The event envelope”Every delivery is one JSON Event per request. The same envelope is used for every type.
{ "id": "evt_7Wq1ZxC4vB", "type": "job.opened", "created_at": "2026-10-11T06:15:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "open" } }}Illustrative and shortened. A real job.opened carries the full Job.
| Field | What it is |
|---|---|
id |
Event id (evt_...). The same on every retry and replay. Use it to dedupe. |
type |
One of the event types. |
created_at |
When the event happened. |
watch_id |
The watch that produced it. null for search.completed. |
data |
job for job events, company and is_hiring for company events, search for search.completed. |
The event catalog lists each type, what it means, what your code should do and what it costs.
Verify the signature
Section titled “Verify the signature”Every delivery carries a BetterJobs-Signature header:
BetterJobs-Signature: t=1760173200,v1=5f2b7c1e9a4d3f6b8c0e2a4d6f8b0c2e4a6d8f0b2c4e6a8d0f2b4c6e8a0d2f4btis a Unix timestamp in seconds.v1is the hex HMAC-SHA256 of the string<t>.<raw body>, keyed with your endpoint secret.
To verify:
- Read the raw request body as bytes. Do not parse and re-serialize the JSON first. Any change in whitespace or key order breaks the signature.
- Compute HMAC-SHA256 over
t, a literal., and the raw body. - Compare your result to
v1with a constant-time compare. A plain==leaks timing information. - Reject the delivery if
tis too far from your clock. We suggest 5 minutes. This stops an attacker from replaying a captured request later.
import hashlibimport hmacimport jsonimport osimport time
from fastapi import FastAPI, Request, Response
SECRET = os.environ["BETTERJOBS_WEBHOOK_SECRET"]TOLERANCE_S = 300
app = FastAPI()
def verify_signature(raw_body: bytes, header: str, secret: str) -> bool: pairs = [part.split("=", 1) for part in header.split(",") if "=" in part] t = next((v for k, v in pairs if k == "t"), None) signatures = [v for k, v in pairs if k == "v1"] if t is None or not t.isdigit() or not signatures: return False if abs(time.time() - int(t)) > TOLERANCE_S: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures)
@app.post("/betterjobs/webhook")async def betterjobs_webhook(request: Request) -> Response: raw = await request.body() if not verify_signature(raw, request.headers.get("BetterJobs-Signature", ""), SECRET): return Response(status_code=400) event = json.loads(raw) if not already_processed(event["id"]): # your database enqueue(event) # your queue; do the work later return Response(status_code=200)import { createHmac, timingSafeEqual } from "node:crypto";import express from "express";
const SECRET = process.env.BETTERJOBS_WEBHOOK_SECRET;if (!SECRET) throw new Error("BETTERJOBS_WEBHOOK_SECRET is not set");const TOLERANCE_S = 300;
export function verifySignature(rawBody: Buffer, header: string, secret: string): boolean { const pairs = header.split(",").map((part) => { const i = part.indexOf("="); return [part.slice(0, i), part.slice(i + 1)] as const; }); const t = pairs.find(([k]) => k === "t")?.[1]; const signatures = pairs.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); return signatures.some((sig) => { const given = Buffer.from(sig, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); });}
const app = express();
// express.raw keeps the body as a Buffer, exactly as signed.app.post("/betterjobs/webhook", express.raw({ type: "application/json" }), async (req, res) => { if (!verifySignature(req.body, req.get("BetterJobs-Signature") ?? "", SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString("utf8")); if (!(await alreadyProcessed(event.id))) await enqueue(event); // your database and queue res.sendStatus(200);});Keep the secret in an environment variable or secret store. Never commit it, and never log the header.
Test your verifier
Section titled “Test your verifier”Sign a body yourself and send it to your local endpoint. This checks your parsing and compare logic without waiting for a real event.
import hashlibimport hmacimport osimport time
body = b'{"id":"evt_test_0001","type":"job.opened","created_at":"2026-10-11T06:15:00Z","watch_id":null,"data":{}}'t = str(int(time.time()))v1 = hmac.new(os.environ["BETTERJOBS_WEBHOOK_SECRET"].encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()print(f"BetterJobs-Signature: t={t},v1={v1}")Respond fast
Section titled “Respond fast”Return any 2xx within 10 seconds. Anything else counts as a failed delivery: a non-2xx status, a timeout, or a connection error.
Do the real work after you respond. Write the event to a queue or table, return 200, and process it in a worker. A slow CRM call inside the handler turns into timeouts and retries.
Retries
Section titled “Retries”A failed delivery is retried with backoff for 24 hours. The retries happen this long after the first failed attempt:
- 1m
- 5m
- 30m
- 2h
- 6h
- 12h
- 24h
Retries are free, including retries of job.opened. You are charged once per event, not once per attempt. After the last retry, BetterJobs stops sending. The event is still in the event feed, and you can replay it.
Make handlers idempotent
Section titled “Make handlers idempotent”You will see the same event more than once: after a retry, after a replay, or when your endpoint timed out after it had already done the work. Design for it.
- Dedupe on
id. Store each processed eventid. If it is already stored, return200and stop. - Upsert jobs by
data.job.id. The canonical job id is stable across sources and events. Write with upsert, never blind insert. - Key side effects on the job, not the event. Before you start a sequence for
job.opened, check that you have not already started one for thatdata.job.id. - Handle any order. Do not assume
job.openedarrives beforejob.updatedorjob.closedfor the same job. Comparecreated_atand keep the newest state.
Replay an event
Section titled “Replay an event”Fixed a broken endpoint? Re-send any event with POST /v1/webhooks/replay. It goes to the event’s original webhook endpoint and returns 202 with status: queued. Replays are free and never charge again.
curl https://api.betterjobs.cc/v1/webhooks/replay \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_7Wq1ZxC4vB" }'import osimport requests
resp = requests.post( "https://api.betterjobs.cc/v1/webhooks/replay", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, json={"event_id": "evt_7Wq1ZxC4vB"}, timeout=30,)resp.raise_for_status()print(resp.json()) # {"event_id": "evt_7Wq1ZxC4vB", "status": "queued"}const res = await fetch("https://api.betterjobs.cc/v1/webhooks/replay", { method: "POST", headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", "Content-Type": "application/json", }, body: JSON.stringify({ event_id: "evt_7Wq1ZxC4vB" }),});if (res.status !== 202) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);console.log(await res.json()); // { event_id: "evt_7Wq1ZxC4vB", status: "queued" }Recover missed events
Section titled “Recover missed events”Webhooks are the fast path. GET /v1/events is the record. Every event from your watches and async searches appears there, oldest first, whether or not it was delivered.
To recover after an outage, page through the feed from your last saved position. Store the next_cursor of the last page you processed and pass it as since next time. Your dedupe on id makes overlap harmless. Reading the feed is free. See Pagination and Sync patterns.
What it costs
Section titled “What it costs”job.opened: 1 credit per event delivered, free if you already paid for that job.- Every other event type: free.
- Retries and replays: free.
- Creating a watch and reading
GET /v1/events: free.