# Freshness and lifecycle

> How fresh is a job, and how do I know when it opened, reposted or closed?

Source: https://docs.betterjobs.cc/concepts/freshness-and-lifecycle/

Every job carries its own timestamps and a lifecycle status. You do not have to trust a freshness claim. You can read it off each record.

## The timestamps

| Field                     | Set by       | Meaning                                               | Can be `null`                           |
| ------------------------- | ------------ | ----------------------------------------------------- | --------------------------------------- |
| `posted_at`               | The employer | Date the employer posted the job, if known.           | Yes. Many postings do not state it.     |
| `first_seen_at`           | Any source   | Earliest time any source saw the job.                 | No                                      |
| `last_seen_at`            | Any source   | Latest time any source saw the job.                   | No                                      |
| `last_verified_at`        | Origin check | Latest time the job was confirmed live at its origin. | Yes. `null` = never verified at origin. |
| `sources[].first_seen_at` | One provider | When this provider first saw the job.                 | No                                      |
| `sources[].last_seen_at`  | One provider | When this provider last saw the job.                  | No                                      |

An illustrative job from the spec:

```json
{
  "posted_at": "2026-10-08T00:00:00Z",
  "first_seen_at": "2026-10-08T06:40:00Z",
  "last_seen_at": "2026-10-11T06:10:00Z",
  "last_verified_at": "2026-10-11T06:10:00Z",
  "status": "open",
  "closed_reason": null,
  "repost_count": 0
}
```

The canonical `first_seen_at` is the earliest of the `sources[].first_seen_at` values. The canonical `last_seen_at` is the latest of the `sources[].last_seen_at` values. So adding sources can only make a job look earlier-found and more recently seen, never staler.

## Which timestamp to use

| You want                         | Use                                                                |
| -------------------------------- | ------------------------------------------------------------------ |
| “New jobs since my last sync”    | `first_seen_at`. It is always set, and a repost does not reset it. |
| “How old is the opening?”        | `posted_at` when set, else `first_seen_at`.                        |
| “Is it still up?”                | `status`, then `last_verified_at` for proof from the origin.       |
| “How stale is this record?”      | Now minus `last_seen_at`.                                          |
| “Which provider found it first?” | The `sources[]` entry with the earliest `first_seen_at`.           |

> posted\_within\_days filters on first\_seen, not posted\_at
>
> `filters.posted_within_days` keeps jobs **first seen** within that many days. A job the employer dated three weeks ago but that BetterJobs first saw yesterday matches `posted_within_days: 7`. If you need the employer’s date, filter `posted_at` yourself after the response.

## Lifecycle

A job is `open` or `closed`. When it closes, `closed_reason` says why.

| `status` | `closed_reason` | Meaning                                            |
| -------- | --------------- | -------------------------------------------------- |
| `open`   | `null`          | Live. `closed_reason` is always `null` while open. |
| `closed` | `filled`        | The role was filled.                               |
| `closed` | `expired`       | The listing ran out.                               |
| `closed` | `removed`       | The listing was taken down.                        |
| `closed` | `unknown`       | It is gone, the reason is not known.               |

A re-listed job is not a new job. It keeps its `id` and `repost_count` goes up. See [Canonical jobs](https://docs.betterjobs.cc/concepts/canonical-jobs.md#reposts).

Searches return only open jobs by default. Send `filters.include_closed: true` to get closed ones too, for example when you backfill history or reconcile a CRM.

**curl**

```bash
curl https://api.betterjobs.cc/v1/jobs/search \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "company_domain_or": ["acme-robotics.example"],
      "posted_within_days": 30,
      "include_closed": true
    },
    "waterfall": { "max_credits": 100 },
    "limit": 100
  }'
```

**Python**

```python
import os
import requests


resp = requests.post(
    "https://api.betterjobs.cc/v1/jobs/search",
    headers={
        "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
        "BetterJobs-Version": "2026-10-01",
    },
    json={
        "filters": {
            "company_domain_or": ["acme-robotics.example"],
            "posted_within_days": 30,
            "include_closed": True,
        },
        "waterfall": {"max_credits": 100},
        "limit": 100,
    },
)
resp.raise_for_status()
for job in resp.json()["data"]:
    print(job["id"], job["status"], job["closed_reason"], job["last_seen_at"])
```

**TypeScript**

```ts
const resp = await fetch('https://api.betterjobs.cc/v1/jobs/search', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
    'BetterJobs-Version': '2026-10-01',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    filters: {
      company_domain_or: ['acme-robotics.example'],
      posted_within_days: 30,
      include_closed: true,
    },
    waterfall: { max_credits: 100 },
    limit: 100,
  }),
});
if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);
const { data } = await resp.json();
for (const job of data) console.log(job.id, job.status, job.closed_reason, job.last_seen_at);
```

## Lifecycle events

Instead of polling, watch a company or a saved search. Each change arrives as a typed event:

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

Only `job.opened` should start outbound. See [Events](https://docs.betterjobs.cc/data/events.md) and [Webhooks](https://docs.betterjobs.cc/platform/webhooks.md).

## How fresh are the sources?

BetterJobs is only as fresh as the sources that see a job first. Each partner provider publishes its own refresh claims. They are theirs, not measured by BetterJobs:

| Source                                                           | Refresh, as published                                                          | Per                            |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ |
| [Reqbeat](https://docs.betterjobs.cc/providers/reqbeat.md)       | Refresh: Corpus refreshed every 3 hours                                        | per Reqbeat docs               |
| [SignalsAPI](https://docs.betterjobs.cc/providers/signalsapi.md) | Recheck: Sources rechecked every 15 minutes                                    | per SignalsAPI docs            |
| [TheirStack](https://docs.betterjobs.cc/providers/theirstack.md) | Discovery: 73% of jobs discovered the same day, 91% by the end of the next day | per TheirStack docs            |
| [JobsPipe](https://docs.betterjobs.cc/providers/jobspipe.md)     | Freshness: Under 6h on Builder, under 1h on Scale                              | per JobsPipe docs              |
| [Coresignal](https://docs.betterjobs.cc/providers/coresignal.md) | Recheck: Active postings rechecked within 24h                                  | per Coresignal docs            |
| [Techmap](https://docs.betterjobs.cc/providers/techmap.md)       | Feeds: Daily country feeds via AWS Data Exchange                               | per Techmap (jobdatafeeds.com) |

The [BetterJobs index](https://docs.betterjobs.cc/providers/betterjobs-index.md) refresh rate will be published at GA. The `freshest_first` strategy asks the fastest-refreshing sources first. See [the waterfall](https://docs.betterjobs.cc/concepts/waterfall.md#strategies).

> Measured freshness at GA
>
> BetterJobs does not yet publish measured freshness numbers for the merged result. They will be published at GA, with the method. Until then, measure it on your own queries: compare `first_seen_at` with `posted_at`, and now with `last_seen_at`, across the jobs you get back.

## Related

- [Canonical jobs](https://docs.betterjobs.cc/concepts/canonical-jobs.md): reposts and stable ids
- [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md): backfill, daily upsert, closing stale jobs
- [Detect hiring changes](https://docs.betterjobs.cc/guides/detect-hiring-changes.md)
- [Field dictionary](https://docs.betterjobs.cc/data/field-dictionary.md#field-job-last_verified_at)
