Source: https://www.puesto.dev/docs/authentication Markdown source: https://www.puesto.dev/docs/authentication.md Description: Authenticate requests with a secret API key sent as a bearer token. # Authentication The Jobs API authenticates every request with a **secret API key**. Keys are prefixed with `sk_` and are sent as a bearer token in the `Authorization` header. ## Creating a key [#creating-a-key] Sign in, select your [team](/home), and open **API Keys** in the team's settings. Create a key, then copy it immediately — the full secret is shown only once at creation time. Store it somewhere safe (a secrets manager or environment variable). Creating and managing keys requires the **Manage Settings** permission on the team. ## Making an authenticated request [#making-an-authenticated-request] Send the key as a bearer token on every request: ```bash curl "https://www.puesto.dev/api/v1/jobs?limit=1" \ -H "Authorization: Bearer sk_your_key_here" ``` ## Unauthorized responses [#unauthorized-responses] Requests with a missing or invalid key return `401 Unauthorized` as an [`application/problem+json`](/docs/errors) body: ```json { "type": "about:blank", "title": "Unauthorized", "status": 401, "detail": "Invalid API key." } ``` ## Key security [#key-security] * **Keep keys secret.** Treat a key like a password — never commit it to source control or expose it in client-side code. * **Use the header, not the query string.** Always send the key in the `Authorization` header so it is not captured in logs or browser history. * **Rotate on exposure.** If a key may have leaked, revoke it from your team's **API Keys** settings and issue a new one. --- Source: https://www.puesto.dev/docs/building-with-ai Markdown source: https://www.puesto.dev/docs/building-with-ai.md Description: Agent-readable Markdown mirrors, an llms.txt index, and a stable OpenAPI spec URL for building on the Jobs API with AI tools. # Building with AI Every page in these docs is available as plain Markdown, and the whole corpus is published in the formats AI coding agents and assistants expect. This page is the map. ## Markdown mirror of any page [#markdown-mirror-of-any-page] Append `.md` to any documentation URL to get the raw Markdown for that page, served as `text/markdown`: ``` https://www.puesto.dev/docs/authentication → HTML https://www.puesto.dev/docs/authentication.md → Markdown ``` This works for every page, including the auto-generated API reference. Each API operation renders as compact Markdown — method and path, parameter tables, response fields, an authenticated `curl` example, and a link to the full spec: ``` https://www.puesto.dev/docs/api/jobs/search-jobs.md ``` You can also grab the Markdown from the UI: use **Copy as Markdown** or **Open in AI** at the top of any docs page. ## llms.txt and the full corpus [#llmstxt-and-the-full-corpus] * [`/llms.txt`](/llms.txt) — a curated index of the documentation with a short set of instructions for agents (auth, conventions, behavior rules). * [`/llms-full.txt`](/llms-full.txt) — the entire documentation corpus as a single Markdown file, ready to paste into a model's context. Both follow the [llms.txt convention](https://llmstxt.org). ## OpenAPI spec [#openapi-spec] The machine-readable OpenAPI 3.1 description of the API lives at a stable URL: * [`/api/v1/openapi.json`](/api/v1/openapi.json) Point code generators, API explorers, or agent tools at this URL to get typed clients and request builders. ## Agent behavior rules [#agent-behavior-rules] If you are configuring an agent to call the Jobs API, follow these rules: * **Never ask a user to paste their secret key into chat.** Keys (prefixed `sk_`) are created in a team's **API Keys** settings and used server-side. * **Send the key in the `Authorization` header**, never in the query string, so it is not captured in logs or history. * **Respect `Retry-After`.** On a `429 Too Many Requests` response, wait for the duration in the `Retry-After` header before retrying. See [Rate limits](/docs/rate-limits). * **Handle RFC 7807 errors.** Failures return `application/problem+json`. See [Errors](/docs/errors). ## MCP server [#mcp-server] An official Model Context Protocol (MCP) server for the Jobs API is planned. Per-client install configurations will be published here when it ships. In the meantime, point your agent at [`/llms.txt`](/llms.txt) and [`/api/v1/openapi.json`](/api/v1/openapi.json). --- Source: https://www.puesto.dev/docs/changes-feed Markdown source: https://www.puesto.dev/docs/changes-feed.md Description: Keep a local copy of the job catalog in sync by polling the append-only change feed. # Syncing with the changes feed The changes feed (`GET /api/v1/changes`) is an **append-only log of job change events**. Every time a job is created, updated, closed, or reopened, an event is appended with a monotonically increasing integer `id`. By remembering the last id you processed and asking for everything after it, you can keep a local copy of the catalog in sync without re-crawling. ## The feed model [#the-feed-model] A successful `GET /api/v1/changes` response has this shape: ```json { "data": [ { "id": 4821, "job_id": 12345, "change_type": "created", "changed_at": "2026-06-30T08:15:00+00:00" }, { "id": 4822, "job_id": 11876, "change_type": "closed", "changed_at": "2026-06-30T08:17:42+00:00" } ], "next_cursor": "4822", "has_more": true, "limit": 100 } ``` * **`id`** is the monotonic position of the event in the feed. Events are always returned in ascending `id` order. * **`change_type`** is one of `created`, `updated`, `closed`, or `reopened`. * **`next_cursor`** is **always present** — pass it back as the `cursor` query parameter on your next request. When there are no new events, it simply echoes the cursor you sent. * **`has_more`** tells you whether to keep paging immediately (see below). ## Initial sync: replaying history [#initial-sync-replaying-history] Omit `cursor` (or send `cursor=0`) to replay the full feed from the very beginning: ```bash curl "https://www.puesto.dev/api/v1/changes?limit=500" \ -H "Authorization: Bearer sk_your_key_here" ``` Keep requesting with each response's `next_cursor` until `has_more` is `false` and you have consumed the entire history. ## Persisting the cursor [#persisting-the-cursor] After processing each page, **durably store `next_cursor`** (a database row, a file — anywhere that survives restarts). It is the only state you need: on the next poll, resume exactly where you left off: ```bash curl "https://www.puesto.dev/api/v1/changes?cursor=4822&limit=500" \ -H "Authorization: Bearer sk_your_key_here" ``` Because the feed is append-only and the cursor is just a position, re-running a page is safe — you may process an event twice, but you can never miss one, as long as you only advance the stored cursor after processing succeeds. ## The poll loop [#the-poll-loop] `has_more` is `true` when the page was full (`data.length === limit`), which means more events are likely waiting: 1. Request `/api/v1/changes?cursor=`. 2. Process `data`, then store `next_cursor`. 3. If `has_more` is `true`, request again **immediately** with the new cursor. 4. If `has_more` is `false`, you are caught up — sleep for your own polling interval (for example a few minutes), then go back to step 1. Note that a final page whose size exactly equals `limit` reports `has_more: true`; the follow-up request then returns an empty `data` array with your cursor echoed back, and you fall through to the sleep. ## Filtering by change type [#filtering-by-change-type] Narrow the feed to the event kinds you care about with `types` — repeat the parameter or comma-separate values: ```bash # Only new and closed jobs curl "https://www.puesto.dev/api/v1/changes?cursor=4822&types=created,closed" \ -H "Authorization: Bearer sk_your_key_here" ``` The cursor semantics are unchanged: `next_cursor` still advances through the underlying feed, skipping events of other types. ## Embedding job records [#embedding-job-records] By default each event carries only the `job_id`. Set `include=job` to embed the current job record (descriptions omitted, company attached) with each change, saving a round trip to `/api/v1/jobs/{id}`: ```bash curl "https://www.puesto.dev/api/v1/changes?cursor=4822&include=job" \ -H "Authorization: Bearer sk_your_key_here" ``` ```json { "data": [ { "id": 4823, "job_id": 12399, "change_type": "created", "changed_at": "2026-06-30T09:02:11+00:00", "job": { "id": 12399, "title": "Senior Software Engineer", "country_code": "US", "workplace": "remote", "posted_at": "2026-06-30T08:55:00+00:00", "company": { "id": 67, "name": "Acme Inc.", "website_url": "https://acme.example", "logo_url": null } } } ], "next_cursor": "4823", "has_more": false, "limit": 100 } ``` The embedded `job` is the job's **current** state (not a snapshot at change time). It is `null` in the rare case the job record is unavailable. ## Recommended sync pattern [#recommended-sync-pattern] To build and maintain a full local mirror: 1. **Backfill** — either page through [`/api/v1/jobs`](/docs/api/jobs/search-jobs) with your filters to seed the current open inventory, **or** replay the full change history via `/api/v1/changes` with no cursor (which also captures closed/historical jobs). 2. **Store the cursor** — after the backfill, persist the latest `next_cursor`. 3. **Consume forever** — run the poll loop above on your own interval. Apply `created`/`updated`/`reopened` events by upserting the job record (use `include=job` or fetch by id) and `closed` events by marking the job closed. Filtering the feed by source is not yet available; filter client-side on the embedded job's `source_id` if you need it. --- Source: https://www.puesto.dev/docs/errors Markdown source: https://www.puesto.dev/docs/errors.md Description: The RFC 7807 problem+json error format, validation error details, and status codes. # Errors Errors are returned as [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) `application/problem+json` documents. The response `Content-Type` is `application/problem+json` and the body always includes at least `type`, `title`, and `status`. ## Problem shape [#problem-shape] ```json { "type": "about:blank", "title": "Invalid query parameters", "status": 400, "detail": "limit: Expected number, received string" } ``` | Field | Type | Description | | -------- | ------- | --------------------------------------------------------- | | `type` | string | A URI reference identifying the problem type. | | `title` | string | A short, human-readable summary of the problem. | | `status` | integer | The HTTP status code. | | `detail` | string | A human-readable explanation specific to this occurrence. | | `errors` | array | Per-field validation errors, when applicable (see below). | ## Validation errors [#validation-errors] When a request fails validation, the problem includes an `errors` array — one entry per offending field: ```json { "type": "about:blank", "title": "Invalid query parameters", "status": 400, "detail": "limit: Expected number, received string; country: Too many items", "errors": [ { "field": "limit", "message": "Expected number, received string" }, { "field": "country", "message": "Too many items" } ] } ``` Each entry has a `field` (the offending field path) and a `message` (what was wrong with it). ## Status codes [#status-codes] | Status | Meaning | | --------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `400 Bad Request` | Invalid query parameters or a malformed pagination cursor. Includes `errors[]` for field-level validation failures. | | `401 Unauthorized` | Missing or invalid API key. See [Authentication](/docs/authentication). | | `404 Not Found` | No job exists with the requested id. | | `429 Too Many Requests` | Per-account rate limit exceeded. Includes a `Retry-After` header. See [Rate limits](/docs/rate-limits). | | `500 Internal Server Error` | An unexpected error occurred on our side. | --- Source: https://www.puesto.dev/docs Markdown source: https://www.puesto.dev/docs.md Description: A REST API for searching and retrieving job postings, with cursor pagination and a rich set of filters. # Introduction The Jobs API is a read-only REST API for searching and retrieving job postings. It exposes two resources — a paginated job search and a single job lookup — both returning JSON. ## Base URL [#base-url] All endpoints live under the `/api/v1` prefix: ``` https://www.puesto.dev/api/v1 ``` ## Authentication [#authentication] Every request must include a secret API key (prefixed `sk_`) as a bearer token: ``` Authorization: Bearer sk_your_key_here ``` Create and manage keys from your [team's](/home) **API Keys** settings. See [Authentication](/docs/authentication) for details. ## Quickstart [#quickstart] Fetch the three most recent jobs in the United States: ```bash curl "https://www.puesto.dev/api/v1/jobs?country=US&limit=3" \ -H "Authorization: Bearer sk_your_key_here" ``` A successful response is a page of job summaries plus a `next_cursor` for fetching the following page: ```json { "data": [ { "id": 12345, "title": "Senior Software Engineer", "country_code": "US", "workplace": "remote", "posted_at": "2024-05-01T12:00:00Z", "company": { "id": 67, "name": "Acme Inc.", "website_url": "https://acme.example", "logo_url": null } } ], "next_cursor": "eyJ2IjoicG9zdGVkX2F0Ii...", "limit": 3 } ``` ## Next steps [#next-steps] * [Authentication](/docs/authentication) — how to create and use API keys. * [Pagination](/docs/pagination) — the cursor model, sorting, and the default freshness window. * [Errors](/docs/errors) — the `application/problem+json` error format. * [API Reference](/docs/api/jobs/search-jobs) — the full, auto-generated reference with an interactive playground. --- Source: https://www.puesto.dev/docs/pagination Markdown source: https://www.puesto.dev/docs/pagination.md Description: Cursor-based keyset pagination, sort options, page size limits, and the default freshness window. # Pagination & sorting The job search endpoint uses **cursor-based (keyset) pagination**. Each response returns a page of results and an opaque `next_cursor` you pass back to fetch the next page. ## The cursor model [#the-cursor-model] A successful `GET /api/v1/jobs` response has this shape: ```json { "data": [ /* job summaries */ ], "next_cursor": "eyJ2IjoicG9zdGVkX2F0Ii...", "limit": 100 } ``` * **`next_cursor`** is an opaque string. Pass it as the `cursor` query parameter on the next request to fetch the following page. * When there are no more results, `next_cursor` is `null`. * A cursor is bound to the `sort` value that produced it. You must send the **same `sort`** when using a cursor, otherwise the request is rejected with a `400`. ### Walking through pages [#walking-through-pages] ```bash # First page curl "https://www.puesto.dev/api/v1/jobs?country=US&limit=100" \ -H "Authorization: Bearer sk_your_key_here" # Next page — pass the previous next_cursor curl "https://www.puesto.dev/api/v1/jobs?country=US&limit=100&cursor=eyJ2Ii..." \ -H "Authorization: Bearer sk_your_key_here" ``` Keep requesting with the latest `next_cursor` until it comes back `null`. ## Page size [#page-size] Control page size with `limit`: * **Default:** `100` * **Maximum:** `500` — values above 500 are clamped to 500. The `limit` field echoed in the response is the effective page size applied to that request. ## Sorting [#sorting] The `sort` parameter selects the field for the descending keyset pagination: | Value | Description | | --------------- | -------------------------------------------- | | `posted_at` | When the job was posted (default). | | `first_seen_at` | When the job was first seen by our crawlers. | Results are always returned in descending order of the sort field. ## Default freshness window [#default-freshness-window] When you query open listings (`status=open`, the default) **and** you do not supply any of `posted_since`, `posted_until`, or `first_seen_since`, results are limited to jobs posted in the **last 90 days**. Provide an explicit date filter to search outside that window. --- Source: https://www.puesto.dev/docs/rate-limits Markdown source: https://www.puesto.dev/docs/rate-limits.md Description: Per-account request limits, the 429 response, and how to back off correctly. # Rate limits The API enforces a per-account request rate limit to keep the service fast and fair for everyone. When you exceed it, requests are rejected with `429 Too Many Requests` until the window resets. ## The limit [#the-limit] The default limit is **300 requests per 60 seconds per account** — roughly 5 requests per second sustained. This is generous for [changes-feed](/docs/changes-feed) polling and [jobs](/docs/api/jobs/search-jobs) paging while stopping runaway loops. ## Per-account buckets [#per-account-buckets] The limit is tracked **per account, not per API key**. All of an account's API keys share a single bucket, so minting additional keys does not raise your throughput — a request counts against the account regardless of which key made it. ## The 429 response [#the-429-response] When the limit is exceeded, the API returns a standard [problem+json](/docs/errors) document with status `429`: ```json { "type": "about:blank", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded for this account. Retry after the interval in the Retry-After header." } ``` The response includes a `Retry-After` header giving the number of **seconds** to wait before retrying: ``` Retry-After: 60 ``` ## Handling 429s [#handling-429s] * **Honor `Retry-After`.** When you receive a 429, wait at least the number of seconds it specifies before sending another request. * **Back off exponentially** if you keep hitting the limit — increase the delay on each successive 429 rather than retrying in a tight loop. * **Spread work out.** For large syncs, page steadily rather than firing bursts of concurrent requests. ## No usage headers (yet) [#no-usage-headers-yet] The API does **not** currently return `X-RateLimit-Remaining`, `X-RateLimit-Reset`, or similar headers — there is no way to read your remaining quota ahead of time. Rely on the `Retry-After` header on a 429 and pace your requests conservatively. --- Source: https://www.puesto.dev/docs/api/changes/list-job-changes Markdown source: https://www.puesto.dev/docs/api/changes/list-job-changes.md Description: Poll the append-only feed of job change events for incremental sync. Pages forward by a monotonic integer `cursor`; omit the cursor to replay the full history from the beginning (initial sync). Always pass the returned `next_cursor` back on the next poll. When `has_more` is true, keep paging immediately; when false, poll again on your own interval. Set `include=job` to embed the current job record (descriptions omitted) with each change. Use `since` (an ISO-8601 timestamp with offset) as a convenience alternative to `cursor` for starting the feed at a point in time; it is mutually exclusive with a non-zero `cursor`. Snapshot filters (`level`, `workplace`, `employment_type`, `country`, `company_id`, `has_salary`) use before-OR-after matching: an event is delivered if the job matched the filter before the change or after it — so you also see jobs leaving your scope (e.g. filtering `level=senior` still delivers the event where a job moves out of the senior level). Different filter dimensions are ANDed together. The `changed_fields` attribution filter delivers only events that changed at least one of the named content fields; `created` events carry no field-level attribution and are always delivered. # List job changes `GET /api/v1/changes` Poll the append-only feed of job change events for incremental sync. Pages forward by a monotonic integer `cursor`; omit the cursor to replay the full history from the beginning (initial sync). Always pass the returned `next_cursor` back on the next poll. When `has_more` is true, keep paging immediately; when false, poll again on your own interval. Set `include=job` to embed the current job record (descriptions omitted) with each change. Use `since` (an ISO-8601 timestamp with offset) as a convenience alternative to `cursor` for starting the feed at a point in time; it is mutually exclusive with a non-zero `cursor`. Snapshot filters (`level`, `workplace`, `employment_type`, `country`, `company_id`, `has_salary`) use before-OR-after matching: an event is delivered if the job matched the filter before the change or after it — so you also see jobs leaving your scope (e.g. filtering `level=senior` still delivers the event where a job moves out of the senior level). Different filter dimensions are ANDed together. The `changed_fields` attribution filter delivers only events that changed at least one of the named content fields; `created` events carry no field-level attribution and are always delivered. ## Parameters ### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string | No | Resume the feed after this change id. Omit to replay the full history from the beginning. Always pass back the `next_cursor` from the previous response. Mutually exclusive with `since` (a non-zero `cursor` alongside `since` is rejected). | | `types` | string[] | No | Filter to specific change kinds. Repeat the parameter or comma-separate values (e.g. `types=created,closed`). A change matches if it is any of the given kinds. | | `level` | string[] | No | Filter by seniority level. Repeat the parameter or comma-separate values. Matches before-OR-after: an event is delivered if the job was at any of these levels before the change or is at one after it (jobs leaving your scope are still delivered). | | `workplace` | string[] | No | Filter by workplace arrangement. Repeat the parameter or comma-separate values. Matches before-OR-after: delivered if the job matched any of these before the change or matches one after it. | | `employment_type` | string[] | No | Filter by employment type. Repeat the parameter or comma-separate values. Matches before-OR-after: delivered if the job matched any of these before the change or matches one after it. | | `country` | string[] | No | Filter by ISO 3166-1 alpha-2 country code(s). Repeat the parameter or comma-separate values (e.g. `country=us,de`). Up to 20 values; case-insensitive. Matches before-OR-after: delivered if the job was tagged with any of these countries before the change or matches its current location. | | `company_id` | integer | No | Filter to changes for jobs belonging to this numeric company id. | | `has_salary` | boolean | No | When `true`, deliver events where the job had a salary before the change OR has one after it; when `false`, the inverse. Matches before-OR-after state. | | `changed_fields` | string[] | No | Attribution filter: deliver only events that changed at least one of these content fields. Repeat the parameter or comma-separate values (e.g. `changed_fields=salary_min,salary_max`). `created` events are always delivered (they have no field-level attribution). | | `since` | string | No | Start the feed at this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). A convenience alternative to `cursor` for starting from a point in time; resolved to a cursor server-side. Mutually exclusive with a non-zero `cursor`. | | `limit` | integer | No | Maximum number of changes to return. Defaults to 100; values above 500 are clamped to 500. | | `include` | string | No | When set to `job`, attach the current job record (descriptions omitted) to each change. | ## Response A page of change events. ### Response fields | Field | Type | Description | | --- | --- | --- | | `data` | object[] | The page of change events, ordered by ascending `id`. | | `next_cursor` | string | ALWAYS present — pass it back as `cursor` on the next poll. Echoes your cursor when no new events are available. | | `has_more` | boolean | Whether more events may be immediately available. Keep paging while true. | | `limit` | integer | The effective page size applied to this response. | ## 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/changes" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/jobs/count-jobs Markdown source: https://www.puesto.dev/docs/api/jobs/count-jobs.md Description: Count the jobs matching a filter set. Accepts the same filters as Search jobs (pagination, sorting, and description knobs are ignored) and returns only the total. The default 90-day freshness window applies identically to the count. A single-country count uses the job's primary country and can be slightly lower than the number of results Search jobs returns for the same filter (jobs listing that country as a secondary location are excluded); multi-country filters count by any-listed-country, matching search exactly. Supplying `q` counts full-text matches with the same syntax, scope tiers, symbol-token handling, and guardrails as Search jobs (a broad `q` may return 422; a malformed `q`, 400). A `q` count uses any-listed-country semantics, so with a single-country filter it counts by any-listed-country (matching search), not the primary-country fast path used without `q`. # Count jobs `GET /api/v1/jobs/count` Count the jobs matching a filter set. Accepts the same filters as Search jobs (pagination, sorting, and description knobs are ignored) and returns only the total. The default 90-day freshness window applies identically to the count. A single-country count uses the job's primary country and can be slightly lower than the number of results Search jobs returns for the same filter (jobs listing that country as a secondary location are excluded); multi-country filters count by any-listed-country, matching search exactly. Supplying `q` counts full-text matches with the same syntax, scope tiers, symbol-token handling, and guardrails as Search jobs (a broad `q` may return 422; a malformed `q`, 400). A `q` count uses any-listed-country semantics, so with a single-country filter it counts by any-listed-country (matching search), not the primary-country fast path used without `q`. ## Parameters ### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | Case-insensitive substring match on the job title (3-200 characters). | | `q` | string | No | Full-text search across the job. Google-style syntax: quoted `"exact phrase"`, `OR`, and `-term` exclusion (a query that is ONLY exclusions is rejected with 400). Operator precedence: implicit AND binds tighter than `OR`, and a `-term` attaches only to its adjacent group — so `"machine learning" OR "data scientist" -senior` parses as `A OR (B AND NOT senior)` and CAN still return Senior ML titles. To exclude a term everywhere, distribute it across each branch: `"machine learning" -senior OR "data scientist" -senior`. By default `q` matches the job TITLE only (precise — best for common role terms); pass `q_scope=full` to also search the description. Phrase queries under `q_scope=full` combined with very narrow filters (a small country, a rare language) can hit the search timeout; the default title scope is immune, so prefer it for phrase precision. `q` composes as a filter with the standard `sort`/`cursor` pagination (newest-first over the matches) and respects the default 90-day freshness window unless a date filter is given. Symbol-bearing tech tokens (`c++`, `c#`, `.net`) are matched as a title substring instead of full-text (they collapse under English tokenization). If a full-text search yields no rows a single title-substring fallback runs and its results are flagged `fuzzy: true`. | | `q_scope` | string | No | Which fields `q` searches. `title` (default) matches the job title only — precise, best for common role terms (e.g. `developer`, `engineer`), which otherwise match description boilerplate. `full` also searches the description — broader reach, best for niche skills or tools that appear only in the requirements text (a language or framework not named in the title). Only affects results when `q` is set (ignored otherwise). | | `country` | string[] | No | Filter by ISO 3166-1 alpha-2 country code(s). Repeat the parameter or comma-separate values (e.g. `country=us,de`). A job matches if it is tagged with any of the given countries. Up to 20 values; case-insensitive. | | `state` | string[] | No | Filter by state / region reference code(s). Repeat the parameter or comma-separate values (e.g. `state=CA,NY`). A job matches if it is tagged with any of the given regions. Up to 20 values; case-insensitive. | | `city` | string | No | Case-insensitive substring match on the city name. | | `workplace` | string | No | Filter by workplace arrangement. | | `employment_type` | string | No | Filter by employment type. | | `level` | string | No | Filter by seniority level. | | `language` | string | No | Filter by ISO 639-1 language code (2 letters, case-insensitive). | | `salary_min` | number | No | Keep jobs whose stated salary range reaches at least this amount. Jobs that state only a minimum (no maximum) match when that minimum is at least this value. | | `salary_max` | number | No | Keep jobs whose stated salary range starts at or below this amount. | | `salary_currency` | string | No | Filter by ISO 4217 currency code (3 letters, case-insensitive). | | `has_salary` | boolean | No | When `true`, return only jobs that specify a salary; when `false`, only jobs without a salary. | | `company_id` | integer | No | Filter by the numeric company id. | | `status` | string | No | Filter by listing status. `open` (default) returns active listings; `closed` returns delisted ones. | | `posted_since` | string | No | Only jobs posted at or after this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). When none of `posted_since`, `posted_until`, or `first_seen_since` are provided and `status` is `open`, results default to the last 90 days. | | `posted_until` | string | No | Only jobs posted at or before this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). | | `first_seen_since` | string | No | Only jobs first seen at or after this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). | ## Response The total number of matching jobs. ### Response fields | Field | Type | Description | | --- | --- | --- | | `count` | integer | Total number of jobs matching the supplied filters. | ## 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/count" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/jobs/get-a-job-by-id Markdown source: https://www.puesto.dev/docs/api/jobs/get-a-job-by-id.md Description: Retrieve a single job by its numeric id, including its company and resolved locations. Closed jobs remain retrievable. # Get a job by id `GET /api/v1/jobs/{id}` Retrieve a single job by its numeric id, including its company and resolved locations. Closed jobs remain retrievable. ## Parameters ### Path parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id` | integer | Yes | The numeric job id. | ## Response The requested job. ### 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_raw` | string \| null | Raw location string from the source, or null. | | `locations_raw` | JsonValue | — | | `location` | string \| null | Human-readable location display string in the posting's original order; may lead with a region label while `country_code` reflects the primary country; or null. | | `city` | string \| null | City, or null. | | `region` | string \| null | Region / state, or null. | | `postal_code` | string \| null | Postal code, or null. | | `country_code` | string \| null | ISO 3166-1 alpha-2 country code, or null. | | `latitude` | number \| null | Latitude, or null. | | `longitude` | number \| null | Longitude, or null. | | `usa` | boolean | Whether the job is located in the United States. | | `country_codes` | string[] \| null | ISO 3166-1 alpha-2 country codes the job is tagged with, or null. | | `admin1_refs` | string[] \| null | First-level administrative division refs, or null. | | `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. | | `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. | | `locations` | object[] | Resolved locations associated with the job. | ## 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/{id}" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/jobs/search-jobs Markdown source: https://www.puesto.dev/docs/api/jobs/search-jobs.md Description: Search the public job catalog with cursor-based pagination. Returns a page of job summaries and a `next_cursor` for the following page. Add `q` for full-text search (Google-style: quoted `"phrases"`, `OR`, `-exclusion`; a pure-exclusion query is rejected). Implicit AND binds tighter than `OR` and a `-term` attaches only to its adjacent group, so `"machine learning" OR "data scientist" -senior` means `A OR (B AND NOT senior)` and can still return Senior ML titles; to exclude everywhere, distribute the term: `"machine learning" -senior OR "data scientist" -senior`. By default `q` matches the job title only (precise — best for common role terms like `developer`/`engineer`, which otherwise match description boilerplate); pass `q_scope=full` to also search the description (broader reach — best for niche skills or tools named only in the requirements text). A phrase query under `q_scope=full` combined with very narrow filters (a small country, a rare language) can hit the search timeout; the default title scope is immune, so prefer it for phrase precision. `q` composes as a filter with the standard sorts: `sort=posted_at` (default) and `sort=first_seen_at` are descending keyset sorts with unbounded cursor pagination, applying `q` as a filter over the newest-first keyset. Symbol-bearing tech tokens (`c++`, `c#`, `.net`) are matched as a title substring rather than full-text (they collapse under English tokenization); a mixed query such as `python c++` still runs as full-text. If a full-text search matches nothing, one title-substring fallback runs automatically and the response is flagged `fuzzy: true`. `q` respects the default 90-day freshness window unless a date filter is supplied. A well-formed but overly broad `q` that exceeds the search time budget returns 422 (narrow the query); a malformed `q` (too complex, or only exclusions) returns 400. # Search jobs `GET /api/v1/jobs` Search the public job catalog with cursor-based pagination. Returns a page of job summaries and a `next_cursor` for the following page. Add `q` for full-text search (Google-style: quoted `"phrases"`, `OR`, `-exclusion`; a pure-exclusion query is rejected). Implicit AND binds tighter than `OR` and a `-term` attaches only to its adjacent group, so `"machine learning" OR "data scientist" -senior` means `A OR (B AND NOT senior)` and can still return Senior ML titles; to exclude everywhere, distribute the term: `"machine learning" -senior OR "data scientist" -senior`. By default `q` matches the job title only (precise — best for common role terms like `developer`/`engineer`, which otherwise match description boilerplate); pass `q_scope=full` to also search the description (broader reach — best for niche skills or tools named only in the requirements text). A phrase query under `q_scope=full` combined with very narrow filters (a small country, a rare language) can hit the search timeout; the default title scope is immune, so prefer it for phrase precision. `q` composes as a filter with the standard sorts: `sort=posted_at` (default) and `sort=first_seen_at` are descending keyset sorts with unbounded cursor pagination, applying `q` as a filter over the newest-first keyset. Symbol-bearing tech tokens (`c++`, `c#`, `.net`) are matched as a title substring rather than full-text (they collapse under English tokenization); a mixed query such as `python c++` still runs as full-text. If a full-text search matches nothing, one title-substring fallback runs automatically and the response is flagged `fuzzy: true`. `q` respects the default 90-day freshness window unless a date filter is supplied. A well-formed but overly broad `q` that exceeds the search time budget returns 422 (narrow the query); a malformed `q` (too complex, or only exclusions) returns 400. ## Parameters ### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | Case-insensitive substring match on the job title (3-200 characters). | | `q` | string | No | Full-text search across the job. Google-style syntax: quoted `"exact phrase"`, `OR`, and `-term` exclusion (a query that is ONLY exclusions is rejected with 400). Operator precedence: implicit AND binds tighter than `OR`, and a `-term` attaches only to its adjacent group — so `"machine learning" OR "data scientist" -senior` parses as `A OR (B AND NOT senior)` and CAN still return Senior ML titles. To exclude a term everywhere, distribute it across each branch: `"machine learning" -senior OR "data scientist" -senior`. By default `q` matches the job TITLE only (precise — best for common role terms); pass `q_scope=full` to also search the description. Phrase queries under `q_scope=full` combined with very narrow filters (a small country, a rare language) can hit the search timeout; the default title scope is immune, so prefer it for phrase precision. `q` composes as a filter with the standard `sort`/`cursor` pagination (newest-first over the matches) and respects the default 90-day freshness window unless a date filter is given. Symbol-bearing tech tokens (`c++`, `c#`, `.net`) are matched as a title substring instead of full-text (they collapse under English tokenization). If a full-text search yields no rows a single title-substring fallback runs and its results are flagged `fuzzy: true`. | | `q_scope` | string | No | Which fields `q` searches. `title` (default) matches the job title only — precise, best for common role terms (e.g. `developer`, `engineer`), which otherwise match description boilerplate. `full` also searches the description — broader reach, best for niche skills or tools that appear only in the requirements text (a language or framework not named in the title). Only affects results when `q` is set (ignored otherwise). | | `country` | string[] | No | Filter by ISO 3166-1 alpha-2 country code(s). Repeat the parameter or comma-separate values (e.g. `country=us,de`). A job matches if it is tagged with any of the given countries. Up to 20 values; case-insensitive. | | `state` | string[] | No | Filter by state / region reference code(s). Repeat the parameter or comma-separate values (e.g. `state=CA,NY`). A job matches if it is tagged with any of the given regions. Up to 20 values; case-insensitive. | | `city` | string | No | Case-insensitive substring match on the city name. | | `workplace` | string | No | Filter by workplace arrangement. | | `employment_type` | string | No | Filter by employment type. | | `level` | string | No | Filter by seniority level. | | `language` | string | No | Filter by ISO 639-1 language code (2 letters, case-insensitive). | | `salary_min` | number | No | Keep jobs whose stated salary range reaches at least this amount. Jobs that state only a minimum (no maximum) match when that minimum is at least this value. | | `salary_max` | number | No | Keep jobs whose stated salary range starts at or below this amount. | | `salary_currency` | string | No | Filter by ISO 4217 currency code (3 letters, case-insensitive). | | `has_salary` | boolean | No | When `true`, return only jobs that specify a salary; when `false`, only jobs without a salary. | | `company_id` | integer | No | Filter by the numeric company id. | | `status` | string | No | Filter by listing status. `open` (default) returns active listings; `closed` returns delisted ones. | | `posted_since` | string | No | Only jobs posted at or after this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). When none of `posted_since`, `posted_until`, or `first_seen_since` are provided and `status` is `open`, results default to the last 90 days. | | `posted_until` | string | No | Only jobs posted at or before this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). | | `first_seen_since` | string | No | Only jobs first seen at or after this ISO-8601 timestamp, including a UTC offset (e.g. `2024-01-01T00:00:00+00:00`). | | `sort` | string | No | Sort field for the descending keyset pagination. Defaults to `posted_at`. A full-text `q` composes with either sort as a filter. | | `limit` | integer | No | Maximum number of jobs to return. Defaults to 100; values above 500 are clamped to 500. | | `cursor` | string | No | Opaque pagination cursor taken from a previous response `next_cursor`. Must be used with the same `sort` value that produced it. | | `description` | string | No | Include the job description in each result: `off` (default, omitted), `text` (markdown as `description_md`), or `html` (`description_html`). | ## Response A page of matching jobs. ### Response fields | Field | Type | Description | | --- | --- | --- | | `data` | object[] | The page of matching jobs. | | `next_cursor` | string \| null | Cursor for the next page, or null when there are no more results. Pass it as `cursor` on the next request with the same `sort`. | | `limit` | integer | The effective page size applied to this response. | | `fuzzy` | boolean | Present and `true` only when the full-text query matched no jobs and these results come from the title-substring fallback lane. Absent otherwise. | ## 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" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/locations/list-countries Markdown source: https://www.puesto.dev/docs/api/locations/list-countries.md Description: List every country available as a `country` filter value. Each `code` is the exact two-letter value the jobs `country` filter accepts. # List countries `GET /api/v1/locations/countries` List every country available as a `country` filter value. Each `code` is the exact two-letter value the jobs `country` filter accepts. ## Response The available countries. ### Response fields | Field | Type | Description | | --- | --- | --- | | `data` | object[] | All countries available as `country` filter values. | ## 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/locations/countries" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/locations/list-states Markdown source: https://www.puesto.dev/docs/api/locations/list-states.md Description: List the states / admin-1 regions of a country available as `state` filter values. Each `code` (e.g. `US-CA`) is the exact value the jobs `state` filter accepts. # List states `GET /api/v1/locations/states` List the states / admin-1 regions of a country available as `state` filter values. Each `code` (e.g. `US-CA`) is the exact value the jobs `state` filter accepts. ## Parameters ### Query parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `country` | string | Yes | ISO 3166-1 alpha-2 country code to list regions for (2 letters, case-insensitive). | ## Response The available states / regions. ### Response fields | Field | Type | Description | | --- | --- | --- | | `data` | object[] | The states / regions of the requested country usable as `state` filter values. | ## 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/locations/states" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/meta/get-the-openapi-specification Markdown source: https://www.puesto.dev/docs/api/meta/get-the-openapi-specification.md Description: Returns the public OpenAPI 3.1 document describing every endpoint, as JSON. Use it to generate typed clients, import the API into explorers like Postman or Bruno, or point AI agents and tooling at the API. Publicly accessible — no API key required. # Get the OpenAPI specification `GET /api/v1/openapi.json` Returns the public OpenAPI 3.1 document describing every endpoint, as JSON. Use it to generate typed clients, import the API into explorers like Postman or Bruno, or point AI agents and tooling at the API. Publicly accessible — no API key required. ## Response The OpenAPI 3.1 document. ## 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/openapi.json" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json --- Source: https://www.puesto.dev/docs/api/meta/list-vocabulary-values Markdown source: https://www.puesto.dev/docs/api/meta/list-vocabulary-values.md Description: Enumerate the accepted values for each enum-backed filter parameter (and the `salary_period` field). Use these to build filter UIs without hard-coding values. # List vocabulary values `GET /api/v1/meta/vocab` Enumerate the accepted values for each enum-backed filter parameter (and the `salary_period` field). Use these to build filter UIs without hard-coding values. ## Response The controlled vocabulary values. ### Response fields | Field | Type | Description | | --- | --- | --- | | `workplace` | string[] | Accepted values for the `workplace` filter. | | `employment_type` | string[] | Accepted values for the `employment_type` filter. | | `level` | string[] | Accepted values for the `level` filter. | | `salary_period` | string[] | Possible values of the `salary_period` field on a job (no filter parameter). | | `status` | string[] | Accepted values for the `status` filter. | ## 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/meta/vocab" \ -H "Authorization: Bearer sk_your_key_here" ``` Full spec: https://www.puesto.dev/api/v1/openapi.json