# Retrieve a job

> Fetch a single posting by id, with its description if you ask for one.

Source: https://hyperjobs.io/docs/api-reference/jobs-retrieve

<EndpointHeader method="GET" path="/v1/jobs/{id}" />

One posting by id. This is where you ask for a description — pulling them 200 at
a time from [`/v1/jobs`](/docs/api-reference/jobs-list) is the usual reason that
endpoint feels slow.

<CodeGroup>
```bash title="cURL"
curl -H "Authorization: Bearer $HYPERJOBS_KEY" \
  "https://api.hyperjobs.io/v1/jobs/ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33?description_format=text"
```

```js title="Node"
const id = "ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33";

const res = await fetch(
  `https://api.hyperjobs.io/v1/jobs/${encodeURIComponent(id)}?description_format=text`,
  { headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` } },
);
if (res.status === 404) return null; // unknown id, or folded into a duplicate

const job = await res.json(); // the job itself — not { data: … }
```

```python title="Python"
import os, requests
from urllib.parse import quote

job_id = "ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33"

res = requests.get(
    f"https://api.hyperjobs.io/v1/jobs/{quote(job_id, safe='')}",
    params={"description_format": "text"},
    headers={"Authorization": f"Bearer {os.environ['HYPERJOBS_KEY']}"},
    timeout=30,
)
job = res.json() if res.status_code == 200 else None
```
</CodeGroup>

## Path parameters

<Fields>
  <ParamField path="id" type="string" required>
    The job's `id`, exactly as it appears in a `/v1/jobs` or `/v1/jobs/feed`
    response. Ids are source-prefixed — `ashby:7c1f…`, `greenhouse:12345` — and
    are matched exactly, with no normalisation beyond URL-decoding.

    The colon is legal in a path segment, so encoding is optional in practice.
    Encode anyway (`encodeURIComponent`): ids come from upstream ATS systems and
    aren't guaranteed to stay free of characters that need it.
  </ParamField>
</Fields>

## Query parameters

<Fields>
  <ParamField query="description_format" type="&quot;text&quot; | &quot;html&quot;" default="omitted">
    Include the description, as clean plain `text` or sanitized `html`.
    Omitted by default — the `description` key is **absent** from the response
    entirely, not null. Any other value is a `400`.
  </ParamField>
</Fields>

<Note>
  No other parameters — and strictly so: any query parameter other than
  `description_format` is rejected with a `400`. Filters don't apply to a
  lookup by id. Unlike [`/v1/jobs`](/docs/api-reference/jobs-list), there's no
  `expires_at` condition layered on either — an expired posting stays
  retrievable by id while it's still in the pool. See the 404 semantics below.
</Note>

## Response

The job object itself, **not wrapped**:

```json title="Response"
{
  "id": "ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33",
  "title": "Staff Backend Engineer",
  "company": {
    "slug": "linear",
    "name": "Linear",
    "domain": "linear.app",
    "website": "https://linear.app",
    "logo": "https://logo.hyperjobs.io/linear.app",
    "industry": "Software Development",
    "employee_count": 120
  },
  "location": {
    "remote": true,
    "countries": ["United States"],
    "cities": null,
    "regions": null,
    "coords": null,
    "timezones": ["America/New_York"],
    "continent": "North America",
    "raw": ["Remote (US)"]
  },
  "employment_type": ["FULL_TIME"],
  "work_arrangement": "Remote",
  "office_days": null,
  "remote_location": ["United States"],
  "department": "Engineering",
  "team": "Sync Engine",
  "requisition_id": "ENG-142",
  "seniority": "Mid-Senior level",
  "experience_level": "5-10",
  "salary": {
    "min": 190000,
    "max": 240000,
    "currency": "USD",
    "unit": "YEAR",
    "annual_min": 190000,
    "annual_max": 240000,
    "summary": "$190,000 – $240,000 a year"
  },
  "working_hours": 40,
  "skills": ["typescript", "graphql", "postgresql"],
  "keywords": ["distributed systems", "real-time sync"],
  "taxonomies": ["Software"],
  "education_requirements": ["bachelor degree"],
  "visa_sponsorship": null,
  "job_language": "en",
  "description": "About Linear\n\nLinear is building…",
  "requirements_summary": "8+ years building distributed backend systems…",
  "core_responsibilities": "Own the sync engine's server-side…",
  "benefits": ["Equity", "Health, dental & vision", "401(k) matching"],
  "hiring_manager": null,
  "apply_url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
  "url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
  "source": "ashby",
  "source_type": "ats",
  "source_domain": "jobs.ashbyhq.com",
  "posted_at": "2026-07-08T14:02:11.000Z",
  "expires_at": null,
  "modified_at": "2026-07-12T08:30:05.000Z",
  "modified_fields": ["salary_max"],
  "created_at": "2026-07-08T14:10:22.000Z",
  "updated_at": "2026-07-14T02:15:40.000Z"
}
```

<Warning title="This response is not wrapped in `data`">
  [`/v1/jobs`](/docs/api-reference/jobs-list) returns `{ data, total, limit,
  offset, next_cursor }`. This endpoint returns the job at the top level. A
  shared response handler that reaches for `.data` will get `undefined` here.
</Warning>

Every field is documented in [the Job object](/docs/objects/job).
`employment_type`, `education_requirements`, and `benefits` are real JSON
arrays — no `JSON.parse` needed. A set of fields is worth
knowing about: `department`, `team`, `requisition_id`, `working_hours`,
`keywords`, `office_days`, `remote_location`, `hiring_manager`,
`source_domain`, `location.coords` / `location.timezones` /
`location.continent`, and the change-tracking trio `modified_at` /
`modified_fields` / `created_at`. And `title` is still de-SHOUTed for display,
so it may not be the raw stored string that `?title=` searches on the list
endpoint.

## Errors

| Status | Cause |
| --- | --- |
| `400` | Any query parameter other than `description_format`, or a bad value for it. |
| `401` | Missing, unknown, or revoked key. |
| `404` | No posting with that id. |
| `429` | [Rate limit](/docs/rate-limits) exceeded, or monthly quota exhausted. |
| `500` | Server error. Retry with backoff. |

```json title="404"
{ "error": "not found" }
```

<Note title="What a 404 means — and what it doesn't">
  A `404` means the id is unknown, or the posting was **folded into another one
  as a duplicate**: the same job syndicated to several boards collapses to one
  surviving row, and the absorbed ids stop resolving. See
  [duplicates](/docs/guides/deduplication).

  What it does *not* mean is "expired". An expired posting stays retrievable by
  id while it's still in the pool — check `expires_at` on the payload if you
  care. When you need to know which cached ids to purge, don't poll them one by
  one: [`/v1/jobs/expired`](/docs/api-reference/jobs-expired) is the purge
  feed. If an id does come back `404`, treat it as "delete my copy" rather than
  as an error to retry — retrying won't help, it'll just spend
  [quota](/docs/rate-limits).
</Note>

<Note>
  `/v1/jobs/feed` is the [feed endpoint](/docs/api-reference/jobs-feed), not a
  job whose id is `feed` — the route excludes that one word explicitly. No real
  id collides with it, since every id is source-prefixed.
</Note>
