# Detect hiring changes

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

Source: https://docs.betterjobs.cc/guides/detect-hiring-changes/

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`.

## Steps

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.

## 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](https://docs.betterjobs.cc/data/events.md).

> job.reposted is not a new job
>
> When an employer re-lists the same opening, BetterJobs keeps the same canonical job `id`, raises `repost_count` and sends `job.reposted`. It does not send `job.opened` again. If you start a sequence on every event, the same contact gets the same email each time the job is re-listed. Start outbound on `job.opened` only.

## Create a watch

Two kinds. A company watch follows one domain. A search watch follows a saved `filters` object, using the same [filter grammar](https://docs.betterjobs.cc/platform/filters.md) as search.

**curl**

```bash
# 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"]
  }'
```

**Python**

```python
import os
import 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"])
```

**TypeScript**

```ts
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):

```json
{
  "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

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.

**curl**

```bash
# 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"
```

**Python**

```python
import hashlib
import hmac
import os
import 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 clock
seen_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 "", 200
```

**TypeScript**

```ts
import { 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 production
const 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

`job.opened` carries the full job (illustrative, trimmed):

```json
{
  "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:

```json
{
  "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
    }
  }
}
```

```json
{
  "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

| 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](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

## Pitfalls

> hiring\_stopped never fires on unknown
>
> `company.hiring_stopped` fires only when `is_hiring.value` changes to `false`. A change to `null` (unknown) sends nothing. Do not read silence as “stopped hiring”, and never treat `null` as `false`. See [Provenance and confidence](https://docs.betterjobs.cc/concepts/provenance-and-confidence.md).

> Webhooks are a plan feature
>
> Webhooks are listed on the Pro plan and above in the [plan table](https://docs.betterjobs.cc/concepts/credits-and-billing.md). When a request needs a feature your plan lacks, the API returns `403` [`plan_required`](https://docs.betterjobs.cc/platform/errors.md#plan_required) with `required_plan` and `upgrade_url`.

- **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](https://docs.betterjobs.cc/guides/sync-patterns.md#recover-from-an-outage).

## Next

- [Webhooks](https://docs.betterjobs.cc/platform/webhooks.md): signature, retries and replay in detail.
- [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md): keep a full local copy using searches plus free lifecycle events.
- API reference: [Create a watch](/api/operations/createwatch/), [List events](/api/operations/listevents/), [Replay a webhook](/api/operations/replaywebhook/), [Event delivery](/api/webhooks/webhookevent/).
