Skip to content
API v1 preview — endpoints and fields may change before general availability.

Detect hiring changes

How do I get told when a company opens, reposts or closes a job, or starts or stops hiring?

View .md
Cost: 1 credit / job.opened deliveredCreating a watch, reading /events, every other event type, retries and replays are free.

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.

  1. Create a watch. Use type: company with a domain, or type: search with a filters object. List only the event types you act on in events.

  2. Verify every delivery. Check the BetterJobs-Signature header before you trust the body.

  3. Deduplicate on event id. A retry or a replay sends the same event again.

  4. Branch on type. Start outbound on job.opened only. Update your records on the others.

  5. Answer fast. Return any 2xx within 10 seconds. Queue slow work.

EventWhat happenedWhat to dodataCost
job.openedA 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.repostedThe 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.closedThe 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.updatedA 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_startedis_hiring.value for a watched company changed to true.Good moment for account-level outreach.company, is_hiringFree
company.hiring_stoppedis_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_hiringFree

The full catalog, search.completed included, is on Events.

Two kinds. A company watch follows one domain. A search watch follows a saved filters object, using the same filter grammar as search.

Terminal window
# Watch one company
curl 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 search
curl 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"]
}'

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.

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.

Terminal window
# 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"

upsert_job, start_outreach and the other handlers are yours: your database, CRM or sequencer.

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"
}
}
}
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.

  • 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.opened can land after a later job.updated for the same job. Upsert on job id and keep the newer last_seen_at.
  • Slow handlers cause retries. If you call a CRM or LLM, put the event on a queue and return 2xx first.
  • Closed jobs stay in your data. On job.closed, set status and closed_reason. Do not delete the job: it is your hiring history.
  • Missed events are not lost. Read GET /v1/events with since to catch up. See Sync patterns.