Detect hiring changes
How do I get told when a company opens, reposts or closes a job, or starts or stops hiring?
Goal: get a webhook when Acme Robotics opens or closes a job, or when any company posts a new Head of RevOps job in Germany, Austria or Switzerland. Start outreach on new jobs only, never on reposts.
You create a watch. BetterJobs sends each matching event to your webhook_url and keeps a copy in GET /v1/events.
-
Create a watch. Use
type: companywith adomain, ortype: searchwith afiltersobject. List only the event types you act on inevents. -
Verify every delivery. Check the
BetterJobs-Signatureheader before you trust the body. -
Deduplicate on event
id. A retry or a replay sends the same event again. -
Branch on
type. Start outbound onjob.openedonly. Update your records on the others. -
Answer fast. Return any
2xxwithin 10 seconds. Queue slow work.
The events
Section titled “The events”| Event | What happened | What to do | data | Cost |
|---|---|---|---|---|
job.opened | A new canonical job appeared for a watched company or saved search. | Trigger outbound. This is the only event that should start a sequence. | job (full Job) | 1 credit |
job.reposted | The same job was re-listed. repost_count went up. | Do not re-trigger outbound. Update your copy of the job only. | job (id, title, company, status, repost_count) | Free |
job.closed | The job is no longer live. closed_reason says why: filled, expired, removed or unknown. | Stop sequences tied to this job. Mark it closed in your CRM. | job (id, title, company, status, closed_reason) | Free |
job.updated | A field on an open job changed, for example salary or location. | Upsert the job by id. No outbound action. | job (full Job) | Free |
company.hiring_started | is_hiring.value for a watched company changed to true. | Good moment for account-level outreach. | company, is_hiring | Free |
company.hiring_stopped | is_hiring.value for a watched company changed to false. A change to null (unknown) never fires this event. | Pause hiring-based plays for this account. | company, is_hiring | Free |
The full catalog, search.completed included, is on Events.
Create a watch
Section titled “Create a watch”Two kinds. A company watch follows one domain. A search watch follows a saved filters object, using the same filter grammar as search.
# Watch one companycurl https://api.betterjobs.cc/v1/watches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3c1d7a52-8e4f-4b19-a6d0-2f9e5b7c4a10" \ -d '{ "type": "company", "domain": "acme-robotics.example", "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"] }'
# Watch a saved searchcurl https://api.betterjobs.cc/v1/watches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9e2b4f61-0c7a-4d38-b5e1-6a8f3c2d9b04" \ -d '{ "type": "search", "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"] }, "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.reposted", "job.closed"] }'import osimport uuid
import requests
API = "https://api.betterjobs.cc/v1"HEADERS = { "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01",}WEBHOOK_URL = "https://hooks.northwind.example/betterjobs"
def create_watch(body: dict) -> dict: resp = requests.post( f"{API}/watches", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json=body, timeout=30, ) resp.raise_for_status() return resp.json()
company_watch = create_watch({ "type": "company", "domain": "acme-robotics.example", "webhook_url": WEBHOOK_URL, "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"],})search_watch = create_watch({ "type": "search", "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], }, "webhook_url": WEBHOOK_URL, "events": ["job.opened", "job.reposted", "job.closed"],})print(company_watch["id"], search_watch["id"])const API = 'https://api.betterjobs.cc/v1';const headers = { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, 'BetterJobs-Version': '2026-10-01', 'Content-Type': 'application/json',};const webhookUrl = 'https://hooks.northwind.example/betterjobs';
async function createWatch(body: object) { const res = await fetch(`${API}/watches`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json();}
const companyWatch = await createWatch({ type: 'company', domain: 'acme-robotics.example', webhook_url: webhookUrl, events: ['job.opened', 'job.closed', 'company.hiring_started', 'company.hiring_stopped'],});const searchWatch = await createWatch({ type: 'search', filters: { title_or: ['Head of RevOps', 'Head of Revenue Operations'], country_code_or: ['DE', 'AT', 'CH'] }, webhook_url: webhookUrl, events: ['job.opened', 'job.reposted', 'job.closed'],});console.log(companyWatch.id, searchWatch.id);Response, 201 Created (illustrative):
{ "id": "wat_6Np3QyR8tU", "type": "company", "domain": "acme-robotics.example", "filters": null, "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"], "status": "active", "created_at": "2026-10-11T08:00:00Z"}Omit events to receive all job and company events. List watches with GET /v1/watches and remove one with DELETE /v1/watches/{id}. Both are free.
Handle deliveries
Section titled “Handle deliveries”BetterJobs POSTs one event per request. The BetterJobs-Signature header looks like t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 over <t>.<raw body> with your endpoint secret and compare it to v1 in constant time. Use the raw bytes, not re-serialized JSON.
# Your handler was down and you fixed it? Re-send one event (free).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" \ -H "Idempotency-Key: 5a7c9e1b-3d2f-4a6b-8c0e-1f3a5b7d9c2e" \ -d '{ "event_id": "evt_7Wq1ZxC4vB" }'
# Or read what you missed from the event feed (free).curl "https://api.betterjobs.cc/v1/events?limit=100" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01"import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
app = Flask(__name__)SECRET = os.environ["BETTERJOBS_WEBHOOK_SECRET"].encode()TOLERANCE_S = 300 # reject deliveries signed more than 5 minutes from your clockseen_event_ids: set[str] = set() # use a unique column in your database in production
def verify(header: str, body: bytes) -> bool: parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item) t, v1 = parts.get("t", ""), parts.get("v1") if not t.isdigit() or v1 is None or abs(time.time() - int(t)) > TOLERANCE_S: return False expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)
@app.post("/betterjobs")def betterjobs_webhook(): if not verify(request.headers.get("BetterJobs-Signature", ""), request.get_data()): abort(400) event = request.get_json() if event["id"] in seen_event_ids: return "", 200 # retry or replay: already handled seen_event_ids.add(event["id"])
job = event["data"].get("job") match event["type"]: case "job.opened": upsert_job(job) start_outreach(job) # the only event that starts a sequence case "job.reposted": update_repost_count(job["id"], job["repost_count"]) case "job.closed": mark_closed(job["id"], job["closed_reason"]) stop_outreach(job["id"]) case "job.updated": upsert_job(job) case "company.hiring_started" | "company.hiring_stopped": set_account_hiring(event["data"]["company"], event["data"]["is_hiring"]) return "", 200import { createHmac, timingSafeEqual } from 'node:crypto';import { createServer } from 'node:http';
const secret = process.env.BETTERJOBS_WEBHOOK_SECRET;if (!secret) throw new Error('BETTERJOBS_WEBHOOK_SECRET is not set');const seenEventIds = new Set<string>(); // use a unique column in your database in productionconst TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes from your clock
function verify(header: string, body: Buffer): boolean { const parts: Record<string, string> = Object.fromEntries( header.split(',').filter((item) => item.includes('=')).map((item) => { const i = item.indexOf('='); return [item.slice(0, i), item.slice(i + 1)]; }), ); if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_S) return false; const expected = createHmac('sha256', secret!).update(`${parts.t}.`).update(body).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && timingSafeEqual(a, b);}
createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); if (!verify(String(req.headers['betterjobs-signature']), body)) { res.writeHead(400).end(); return; } const event = JSON.parse(body.toString('utf8')); if (!seenEventIds.has(event.id)) { seenEventIds.add(event.id); const job = event.data.job; switch (event.type) { case 'job.opened': upsertJob(job); startOutreach(job); // the only event that starts a sequence break; case 'job.reposted': updateRepostCount(job.id, job.repost_count); break; case 'job.closed': markClosed(job.id, job.closed_reason); stopOutreach(job.id); break; case 'job.updated': upsertJob(job); break; case 'company.hiring_started': case 'company.hiring_stopped': setAccountHiring(event.data.company, event.data.is_hiring); break; } } res.writeHead(200).end(); });}).listen(3000);upsert_job, start_outreach and the other handlers are yours: your database, CRM or sequencer.
Example deliveries
Section titled “Example deliveries”job.opened carries the full job (illustrative, trimmed):
{ "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", "repost_count": 0, "p_real": 0.94 } }}job.reposted and job.closed carry the job id, title, company, status and lifecycle fields only:
{ "id": "evt_3Fh8JkL2pQ", "type": "job.reposted", "created_at": "2026-10-11T07:30:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC2B7Y9MZQ4W8E1R6T3N5K0D", "title": "Senior Robotics Engineer", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "open", "repost_count": 2 } }}{ "id": "evt_1Kp6MnB3vC", "type": "job.closed", "created_at": "2026-10-11T10:00: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": "closed", "closed_reason": "filled" } }}Credit cost
Section titled “Credit cost”| Action | Cost |
|---|---|
POST /v1/watches, GET /v1/watches, DELETE /v1/watches/{id} |
Free |
job.opened delivered by a watch |
1 credit (free if you already paid for that job) |
| Every other event type | Free |
Retries and POST /v1/webhooks/replay |
Free |
GET /v1/events |
Free |
A watch on a company that opens 12 jobs in a month costs up to 12 credits that month (jobs you already paid for are free). If you only need to keep your records current, leave job.opened out of events: the remaining lifecycle events are free. See Credits and billing.
Pitfalls
Section titled “Pitfalls”- Deliveries can arrive twice. Failed deliveries retry for 24 hours (1m, 5m, 30m, 2h, 6h, 12h and 24h after the first failed attempt). A replay sends the same event
id. Store event ids with a unique constraint. - Retries arrive late. A retried
job.openedcan land after a laterjob.updatedfor the same job. Upsert on jobidand keep the newerlast_seen_at. - Slow handlers cause retries. If you call a CRM or LLM, put the event on a queue and return
2xxfirst. - Closed jobs stay in your data. On
job.closed, setstatusandclosed_reason. Do not delete the job: it is your hiring history. - Missed events are not lost. Read
GET /v1/eventswithsinceto catch up. See Sync patterns.
- Webhooks: signature, retries and replay in detail.
- Sync patterns: keep a full local copy using searches plus free lifecycle events.
- API reference: Create a watch, List events, Replay a webhook, Event delivery.