Overview
Section titled “Overview”An event tells you that something changed: a job opened, closed or was re-listed, or a company started or stopped hiring. Events come from two places:
- Watches. Watch a company domain (
type: company) or a saved search (type: search) withPOST /v1/watches. - Async searches.
search.completedfires when a search you started withPOST /v1/searchesfinishes.
Each event is delivered to your webhook_url and also kept in the feed at GET /v1/events.
Event catalog
Section titled “Event catalog”Each event type has one meaning and one recommended action. The What to do column is the part to get right.
| 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 |
search.completed | An async search finished with status completed, partial or failed. | Read results with GET /v1/searches/{id}. Branch on status. | search (id, status, jobs_found) | Free |
Envelope
Section titled “Envelope”Every event has the same envelope. data carries the objects relevant to type.
| Field | Type | Meaning |
|---|---|---|
id |
string |
Event id (evt_...). Use it to dedupe and to replay. |
type |
EventType |
One of the types in the catalog above. |
created_at |
date-time |
When the event was created. |
watch_id |
string | null |
Watch that produced the event. null for search.completed. |
data.job |
object |
Full Job for job.opened and job.updated. Id, title, company, status and lifecycle fields otherwise. |
data.company, data.is_hiring |
object |
For company.hiring_started and company.hiring_stopped. |
data.search |
object |
id, status, jobs_found for search.completed. |
A field missing from data.job is not part of that payload. It is not null and not unknown. Fetch the full job with GET /v1/jobs/{id} if you need it; re-reading a job you already paid for is free.
Samples
Section titled “Samples”Illustrative events from the webhook reference. Companies and domains are fictional.
{ "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" } }}{ "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_5Gt2HyU7iO", "type": "company.hiring_stopped", "created_at": "2026-10-11T10:30:00Z", "watch_id": "wat_2Hb7KsM4pE", "data": { "company": { "id": "cmp_7Jd4FgH1sA", "name": "Globex Analytics", "domain": "globex.example" }, "is_hiring": { "value": false, "confidence": 0.91, "basis": "All 6 open jobs closed in the last 14 days across 3 sources" } }}{ "id": "evt_8Re5TyU1oP", "type": "search.completed", "created_at": "2026-10-11T09:06:45Z", "watch_id": null, "data": { "search": { "id": "srch_2Vd9KqL4mN", "status": "completed", "jobs_found": 3184 } }}A job.opened event carries the full job. Its shape is the record on Jobs.
Handle events
Section titled “Handle events”Route on type, dedupe on id, upsert jobs on data.job.id.
# Read the event feed (free). Pass next_cursor back as since to resume.curl -s "https://api.betterjobs.cc/v1/events?limit=100" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01"def handle(event: dict, seen: set[str]) -> None: if event["id"] in seen: # retries and replays reuse the same id return seen.add(event["id"])
match event["type"]: case "job.opened": start_outbound(event["data"]["job"]) # the only event that starts a sequence case "job.updated": upsert_job(event["data"]["job"]) # full Job; update only, never re-trigger outbound case "job.reposted": job = event["data"]["job"] # partial payload: update repost_count only set_repost_count(job["id"], job["repost_count"]) case "job.closed": stop_sequences(event["data"]["job"]["id"]) case "company.hiring_started" | "company.hiring_stopped": update_account(event["data"]["company"], event["data"]["is_hiring"]) case "search.completed": fetch_results(event["data"]["search"]["id"]) # branch on search statusfunction handle(event: { id: string; type: string; data: any }, seen: Set<string>): void { if (seen.has(event.id)) return; // retries and replays reuse the same id seen.add(event.id);
switch (event.type) { case "job.opened": startOutbound(event.data.job); // the only event that starts a sequence break; case "job.updated": upsertJob(event.data.job); // full Job; update only, never re-trigger outbound break; case "job.reposted": setRepostCount(event.data.job.id, event.data.job.repost_count); // partial payload break; case "job.closed": stopSequences(event.data.job.id); break; case "company.hiring_started": case "company.hiring_stopped": updateAccount(event.data.company, event.data.is_hiring); break; case "search.completed": fetchResults(event.data.search.id); // branch on search status break; }}Delivery
Section titled “Delivery”- BetterJobs POSTs one event per request to your
webhook_url, signed with theBetterJobs-Signatureheader. Verify it before you trust the body. - Return any
2xxwithin 10 seconds. - Failed deliveries retry 1m, 5m, 30m, 2h, 6h, 12h, 24h after the first failed attempt, over 24 hours.
- Replay any event with
POST /v1/webhooks/replay. Replays and retries never charge again. - Missed something? Read
GET /v1/eventsfrom your last cursor. It is free.
Signature verification, retries and local testing are covered in Webhooks.
If your plan does not include watches, POST /v1/watches returns 403 plan_required with required_plan. See Errors.
Coming from a provider’s events
Section titled “Coming from a provider’s events”Some providers publish their own event types. Their names map onto BetterJobs events like this:
| Provider event (per provider docs) | BetterJobs event |
|---|---|
Reqbeat opened |
job.opened |
Reqbeat reposted |
job.reposted |
Reqbeat closed |
job.closed |
Reqbeat reobserved |
No event. last_seen_at moves forward. |
TheirStack job.new |
job.opened |
TheirStack job.closed |
job.closed |
TheirStack company.new |
No direct equivalent. Use a company watch and company.hiring_started. |
| Operation | Endpoint | Cost |
|---|---|---|
| Create a watch | POST /v1/watches |
Cost: FreeEach job.opened the watch delivers costs 1 credit. |
| List watches | GET /v1/watches |
Cost: Free |
| Delete a watch | DELETE /v1/watches/{id} |
Cost: Free |
| List events | GET /v1/events |
Cost: Free |
| Replay a webhook | POST /v1/webhooks/replay |
Cost: Free |
| Event delivery | Your webhook_url |
Cost: 1 credit / job.opened |
Recipe: Detect hiring changes.