Source: https://www.puesto.dev/docs/api/jobs/resolve-a-job-by-url
Markdown source: https://www.puesto.dev/docs/api/jobs/resolve-a-job-by-url.md
Description: Look a job up by the URL of its posting: paste the link a candidate applied through and get back the SAME payload as [Get a job by id](/docs/api/jobs/get-a-job-by-id) — full company record, both description renderings, and the resolved `locations`. Built for application trackers, which hold a URL and need the catalog record behind it.

The URL is parsed server-side into the posting identity the applicant tracking system assigned it, and that identity is looked up. Pass the URL as the candidate sees it: tracking parameters (`?gh_src=`, `utm_*`), locale segments (`/en-US/`), a trailing `/apply` step and `grnh.se` short links are all handled. Only the `url` parameter is read; nothing else varies the result.

**Closed jobs resolve.** A posting taken down years ago answers exactly like a live one — the response is the ordinary job payload with `closed_at` set. That is the point of the endpoint for a tracker: an application made months ago still resolves. The only lookback limit is when we first indexed the board (a job closed before that never existed for us).

**Provisional (live-fetched) results.** A URL we can parse but have not indexed yet may be fetched live from the applicant tracking system and returned on the spot. A provisional result is the SAME `200` JobDetail shape, with one difference: `id`, `source_id` and `company_id` are `null`. The posting is not (yet) a catalog record, so it has no stable id to fetch later by [Get a job by id](/docs/api/jobs/get-a-job-by-id) — but everything else (title, company name, both description renderings, resolved `locations`, salary, level) is populated just like a catalog hit. Treat a `null` `id` as "resolved live, not yet catalogued"; render it, but do not store it as a catalog id or expect `/jobs/{id}` to return it.

**The two 404s.** A miss is never an error on your side, so both cases return `404` with a stable `detail`. Match on the `detail` PREFIX (the sentences below are stable; treat anything after them as free text):

| `detail` | Meaning | What to do |
| --- | --- | --- |
| `Unsupported job board URL.` | The URL is not a job posting URL we can parse — an aggregator link, a bespoke careers site, or a board page rather than a posting. | Stop retrying it. This will not start working. |
| `No job matched the provided URL.` | The URL parsed fine, but nothing in the catalog matches it. | Treat as "not indexed **yet**" and retry later if it matters. |

The second answer is deliberately ONE answer: "we do not index that employer" and "we index that employer but not that posting" are byte-identical, and no field distinguishes them. Do not build logic that tries to. It is worth retrying because coverage grows: a posting less than a day old, or an employer we have just started indexing, can resolve on a later call for the same URL — every miss also feeds our board-discovery queue.

A malformed request — a missing `url`, a non-`http(s)` URL, or one over 2048 characters — is a `400` with the usual validation problem body, not a 404.

**Rate limit.** This endpoint draws on the same shared per-account budget as every other one (see [Rate limits](/docs/rate-limits)); it has no separate allowance. One lookup per tracked application, resolved once and cached on your side, sits comfortably inside it — resolving a whole backlog in a tight loop does not.

# Resolve a job by URL

`GET /api/v1/jobs/resolve`

Look a job up by the URL of its posting: paste the link a candidate applied through and get back the SAME payload as [Get a job by id](/docs/api/jobs/get-a-job-by-id) — full company record, both description renderings, and the resolved `locations`. Built for application trackers, which hold a URL and need the catalog record behind it.

The URL is parsed server-side into the posting identity the applicant tracking system assigned it, and that identity is looked up. Pass the URL as the candidate sees it: tracking parameters (`?gh_src=`, `utm_*`), locale segments (`/en-US/`), a trailing `/apply` step and `grnh.se` short links are all handled. Only the `url` parameter is read; nothing else varies the result.

**Closed jobs resolve.** A posting taken down years ago answers exactly like a live one — the response is the ordinary job payload with `closed_at` set. That is the point of the endpoint for a tracker: an application made months ago still resolves. The only lookback limit is when we first indexed the board (a job closed before that never existed for us).

**Provisional (live-fetched) results.** A URL we can parse but have not indexed yet may be fetched live from the applicant tracking system and returned on the spot. A provisional result is the SAME `200` JobDetail shape, with one difference: `id`, `source_id` and `company_id` are `null`. The posting is not (yet) a catalog record, so it has no stable id to fetch later by [Get a job by id](/docs/api/jobs/get-a-job-by-id) — but everything else (title, company name, both description renderings, resolved `locations`, salary, level) is populated just like a catalog hit. Treat a `null` `id` as "resolved live, not yet catalogued"; render it, but do not store it as a catalog id or expect `/jobs/{id}` to return it.

**The two 404s.** A miss is never an error on your side, so both cases return `404` with a stable `detail`. Match on the `detail` PREFIX (the sentences below are stable; treat anything after them as free text):

| `detail` | Meaning | What to do |
| --- | --- | --- |
| `Unsupported job board URL.` | The URL is not a job posting URL we can parse — an aggregator link, a bespoke careers site, or a board page rather than a posting. | Stop retrying it. This will not start working. |
| `No job matched the provided URL.` | The URL parsed fine, but nothing in the catalog matches it. | Treat as "not indexed **yet**" and retry later if it matters. |

The second answer is deliberately ONE answer: "we do not index that employer" and "we index that employer but not that posting" are byte-identical, and no field distinguishes them. Do not build logic that tries to. It is worth retrying because coverage grows: a posting less than a day old, or an employer we have just started indexing, can resolve on a later call for the same URL — every miss also feeds our board-discovery queue.

A malformed request — a missing `url`, a non-`http(s)` URL, or one over 2048 characters — is a `400` with the usual validation problem body, not a 404.

**Rate limit.** This endpoint draws on the same shared per-account budget as every other one (see [Rate limits](/docs/rate-limits)); it has no separate allowance. One lookup per tracked application, resolved once and cached on your side, sits comfortably inside it — resolving a whole backlog in a tight loop does not.

## Parameters

### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | Yes | The job posting URL to resolve, exactly as it appears in the browser. Must be an absolute `http`/`https` URL of at most 2048 characters. Tracking parameters, locale segments and `/apply` suffixes are tolerated; `grnh.se` short links are followed. |

## Response

The job the URL identifies (open or closed). Usually a catalog record; for a parseable posting not yet indexed it may be a provisional live-fetched row — the same shape with `id`, `source_id` and `company_id` set to `null`.

### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | Numeric job id. |
| `source_id` | integer | Numeric source id. |
| `company_id` | integer | Numeric company id. |
| `external_job_id` | string | The job id assigned by the job's original source. |
| `url` | string | Canonical URL of the job listing. |
| `apply_url` | string \| null | Direct apply URL, or null. |
| `title` | string | Job title. |
| `location` | string \| null | Human-readable location line exactly as the posting words it, in its original order (e.g. `EMEA - Remote; London, England, United Kingdom`), or null. For DISPLAY only — it is source text, not a controlled value. Use `locations` for anything structured (and for the codes that feed the filters). |
| `locations` | object[] | Every place resolved for this job, most-specific-first (city, then state, then country, then multi-country region). ALWAYS present; an empty array means no location could be resolved from the posting — never null. A multi-location job carries each place in full, with per-place association intact (a Dallas entry keeps its own `admin1_name`/`country_code`, rather than being flattened into parallel city/state/country lists). Every entry's `code` is a value the matching filter accepts, so `locations` and the `location`/`state`/`country` filters are closed over each other. |
| `workplace` | string \| null | Workplace arrangement (remote / hybrid / onsite), or null. |
| `employment_type` | string \| null | Employment type, or null. |
| `level` | string \| null | Seniority level, or null when the employer gave no usable signal. Derived, not copied from the posting: `level` reflects the strongest employer signal, in order: explicit title keyword → seniority set in the employer's ATS → stated years-of-experience requirement (0–1 entry / 2–4 mid / 5+ senior) → conservative title fallbacks. A `level` filter excludes these unclassified (null) jobs. |
| `experience_min_years` | integer \| null | Minimum years of experience stated by the employer, or null when not stated. This is the field both the `experience_min` and `experience_max` filters read. |
| `experience_max_years` | integer \| null | Maximum years of experience stated by the employer, or null when not stated. Present only when the posting gave a range ("3-5 years"); returned data only — no filter reads it. |
| `sponsors_visa` | boolean \| null | Tri-state visa-sponsorship flag extracted from the posting text. `true` = the posting states sponsorship IS available; `false` = it explicitly states there is NONE (including a hard U.S.-citizenship requirement); `null` = the posting says nothing we could classify. `null` means UNKNOWN, never "no" — do not render it as "does not sponsor". This is the field the `sponsors_visa` filter reads: its `not_false` mode keeps `true` and `null` rows, while `true` / `false` match the flag exactly and drop the `null` ones. |
| `language` | string \| null | ISO 639-1 language code, or null. |
| `salary_raw` | string \| null | Raw salary string from the source, or null. |
| `salary_min` | number \| null | Minimum of the stated salary range, or null. |
| `salary_max` | number \| null | Maximum of the stated salary range, or null. |
| `salary_currency` | string \| null | ISO 4217 salary currency code, or null. |
| `salary_period` | string \| null | Salary period (e.g. year, hour), or null. |
| `posted_at` | string \| null | When the job was posted as an ISO-8601 date-time string, or null. |
| `expires_at` | string \| null | When the job expires as an ISO-8601 date-time string, or null. |
| `content_hash` | string \| null | Content hash used for change detection, or null. |
| `first_seen_at` | string | When the job was first seen as an ISO-8601 date-time string. |
| `closed_at` | string \| null | When the job was delisted (null for open jobs) as an ISO-8601 date-time string, or null. |
| `created_at` | string | When the job record was created as an ISO-8601 date-time string. |
| `updated_at` | string | When the job record was last updated as an ISO-8601 date-time string. |
| `description_md` | string \| null | Markdown job description, or null. |
| `description_html` | string \| null | HTML job description, or null. |
| `company` | object \| null | Full company record, or null when unknown. |

## Authentication

Send your secret API key (prefixed `sk_`) as a bearer token in the `Authorization` header. See [Authentication](https://www.puesto.dev/docs/authentication.md).

## Errors

Errors use the RFC 7807 `application/problem+json` format. See [Errors](https://www.puesto.dev/docs/errors.md).

## Example request

```bash
curl -X GET "https://www.puesto.dev/api/v1/jobs/resolve" \
  -H "Authorization: Bearer sk_your_key_here"
```

Full spec: https://www.puesto.dev/api/v1/openapi.json

