# Events

> Which events can I receive, and what should my integration do with each one?

Source: https://docs.betterjobs.cc/data/events/

Cost: 1 credit / job.opened deliveredFree if you already paid for that job. Every other event type, retries and replays are free.

## 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`) with `POST /v1/watches`.
- **Async searches.** `search.completed` fires when a search you started with `POST /v1/searches` finishes.

Each event is delivered to your `webhook_url` and also kept in the feed at `GET /v1/events`.

## 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     |

> Only job.opened starts outbound
>
> A re-listed job fires `job.reposted`, not `job.opened`. Treat it as an update. If you start a sequence on every event, you will email the same company again each time the employer refreshes an old posting.

> Unknown is not stopped
>
> `company.hiring_stopped` fires only when `is_hiring.value` changes to `false`. If a company becomes unknown (`null`), no event fires. See [Companies](https://docs.betterjobs.cc/data/companies.md#how-is_hiring-is-decided).

## 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

Illustrative events from the [webhook reference](/api/webhooks/webhookevent/). Companies and domains are fictional.

**job.closed**

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

**job.reposted**

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

**company.hiring\_stopped**

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

**search.completed**

```json
{
  "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](https://docs.betterjobs.cc/data/jobs.md#record-sample).

## Handle events

Route on `type`, dedupe on `id`, upsert jobs on `data.job.id`.

**curl**

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

**Python**

```python
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 status
```

**TypeScript**

```ts
function 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

- BetterJobs POSTs one event per request to your `webhook_url`, signed with the `BetterJobs-Signature` header. Verify it before you trust the body.
- Return any `2xx` within 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/events` from your last cursor. It is free.

Signature verification, retries and local testing are covered in [Webhooks](https://docs.betterjobs.cc/platform/webhooks.md).

If your plan does not include watches, `POST /v1/watches` returns `403 plan_required` with `required_plan`. See [Errors](https://docs.betterjobs.cc/platform/errors.md#plan_required).

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

## API

| Operation                                          | Endpoint                   | Cost                                                         |
| -------------------------------------------------- | -------------------------- | ------------------------------------------------------------ |
| [Create a watch](/api/operations/createwatch/)     | `POST /v1/watches`         | Cost: FreeEach job.opened the watch delivers costs 1 credit. |
| [List watches](/api/operations/listwatches/)       | `GET /v1/watches`          | Cost: Free                                                   |
| [Delete a watch](/api/operations/deletewatch/)     | `DELETE /v1/watches/{id}`  | Cost: Free                                                   |
| [List events](/api/operations/listevents/)         | `GET /v1/events`           | Cost: Free                                                   |
| [Replay a webhook](/api/operations/replaywebhook/) | `POST /v1/webhooks/replay` | Cost: Free                                                   |
| [Event delivery](/api/webhooks/webhookevent/)      | Your `webhook_url`         | Cost: 1 credit / job.opened                                  |

Recipe: [Detect hiring changes](https://docs.betterjobs.cc/guides/detect-hiring-changes.md).
