Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
description: 'Every listing here comes from an employer ATS, a company''s own careers page,
a public employment agency, or a direct submission. Redistribution is
enforced by a database JOIN, not a filter someone can forget.
Three things this API does that a job feed normally does not:
* **It says which fields it guessed.** Every job carries `field_sources`,
naming each field as `published` (carried by the source), `inferred`
(derived by us) or `absent`. 88.0% of live listings have an *inferred*
remote status; 11.3% have one the source actually stated. Filter with
`remote_confirmed=true` to get only the latter.
* **It says when each row was last verified.** `last_verified_at` is when we
last confirmed the vacancy still exists at its source, not when it was
posted. `GET /v1/meta/freshness` publishes the coverage numbers.
* **It keeps what closed.** `GET /v1/jobs/closed` returns roles that have
come off their source, with the reason and the date. A req that opened
and closed in nine days is a hiring signal, not a dead link. The date is
the day JOA''s daily verification confirmed the advert had gone; before
late September 2026 that verification ran in large catch-up batches, so
earlier closure dates show when JOA confirmed a closure, not when the
employer closed the role.
Changelog 2026-10-07: new contract fields `engagement`,
`contract_category`, `agency` and `working_schedule`, each with
`field_sources` provenance and filters (`?engagement=`,
`?contract_category=`, `?agency=`, `?working_schedule=`; absence token
`unknown`, alias `not_stated`). Values appear only when the posting or the
source states them (or, later, when an audited rule establishes them) —
most postings will show `unknown` at launch, and that is the honest answer,
not a defect. `contract_category=permanent` returns only postings that
STATE permanence. Per-field coverage is published on /public/coverage.
`employment_type` is unchanged and frozen.
Counts in this document are not typed in: they come from the daily
statistics snapshot every page of the website uses (GET /public/site-stats):
2,080,732 live employer-direct listings (employer_direct_live) and
129,261 employers as of 2026-10-20.'
summary: A verified ledger of open jobs, with per-field provenance and closure history.
title: Job Opportunities Meta API
version: '2026-10-07'
servers:
- url: https://api.jobopportunitiesapi.org
security:
- bearerAuth: []
tags:
- name: Meta
paths:
/v1/me:
get:
description: 'Costs no records. `usage_today` counts REQUESTS today (the per_day burst limit); the billed unit is records: `records_used_month` of `records_limit_month` (null = unmetered key), resetting at `records_reset_at` (00:00 UTC on the 1st). With a country lock (`records_country_lock`) job rows in that country are not counted; company rows still are.'
responses:
'200':
content:
application/json:
schema:
properties:
created:
type:
- string
- 'null'
key_id:
format: uuid
type: string
key_prefix:
type: string
limits:
properties:
max_page_size:
type: integer
per_day:
type: integer
per_minute:
type: integer
ranked_per_day:
type: integer
type: object
plan:
type: string
records_country_lock:
description: ISO country of a country-locked key (job rows there are not metered).
type:
- string
- 'null'
records_limit_month:
description: Monthly record allowance; null for an unmetered key.
type:
- integer
- 'null'
records_reset_at:
description: When the record allowance resets (1st of next month 00:00 UTC).
format: date-time
type: string
records_used_month:
description: Records charged this calendar month (UTC).
type: integer
renews_or_expires:
type:
- string
- 'null'
status:
type: string
usage_today:
description: Requests today (not records).
type: integer
type: object
description: Key status.
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
summary: Your key, plan and quota
tags:
- Meta
operationId: getV1Me
x-operation-id-source: derived
/v1/me/export-estimate:
get:
description: 'Accepts the same filters as GET /v1/jobs. Free to call: it never returns rows and consumes no record quota. estimated_rows is the query planner''s approximation (EXPLAIN, no execution), not an exact count.'
responses:
'200':
description: estimated_rows, export_cap, would_use, remaining_this_month.
'401':
$ref: '#/components/responses/Unauthorized'
'422':
$ref: '#/components/responses/Unprocessable'
'429':
$ref: '#/components/responses/RateLimited'
summary: Approximately how many rows a search would return, and whether the CSV export…
tags:
- Meta
operationId: getV1MeExportEstimate
x-operation-id-source: derived
/v1/me/usage:
get:
parameters:
- description: Only requests is available today.
in: query
name: metric
schema:
enum:
- requests
type: string
- description: Window size. Defaults to 30.
in: query
name: days
schema:
enum:
- 30
- 90
type: integer
responses:
'200':
description: Daily request counts, oldest first.
'401':
$ref: '#/components/responses/Unauthorized'
'422':
$ref: '#/components/responses/Unprocessable'
'429':
$ref: '#/components/responses/RateLimited'
summary: Requests served per day, for the dashboard's usage chart
tags:
- Meta
operationId: getV1MeUsage
x-operation-id-source: derived
/v1/meta/facets:
get:
description: 'Facet keys: `family`, `country`, `city`, `remote`, `employment`,
`seniority`, `source_type`, `remote_source`, `provider`, `field`, and
the legacy `source`.
`remote_source` counts the provenance split itself — how much of the
ledger''s remote status is stated and how much is our inference.
`provider` is every source name `?provider=` accepts, with its live row
count. `field` is how many live rows carry each field as `published`
rather than inferred — the population `require_fields` selects from.'
responses:
'200':
content:
application/json:
schema:
properties:
data:
additionalProperties:
items:
properties:
count:
type: integer
label:
type: string
value:
type: string
type: object
type: array
type: object
generated_at:
description: 'When these counts were computed. The whole table is
rebuilt in one transaction, so this describes all of it.
Without it, a facet total that differs from
/public/coverage by a few thousand rows is
indistinguishable from a real disagreement — the ledger
moves by thousands an hour.
'
format: date-time
type: string
type: object
description: Facet counts.
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
summary: Every filter value with its live count
tags:
- Meta
operationId: getV1MetaFacets
x-operation-id-source: derived
/v1/meta/freshness:
get:
description: 'The claim the subscription is sold on, made checkable. Every live
listing is re-confirmed at its source on a rolling cycle; this reports
the coverage, plus per-field provenance totals so a buyer can size the
honest subset before they pay for it.'
responses:
'200':
content:
application/json:
schema:
properties:
data:
properties:
closures:
properties:
last_24h:
type: integer
last_7d:
type: integer
type: object
field_coverage:
properties:
description:
type: integer
employment_type:
type: integer
remote_inferred:
type: integer
remote_inferred_pct:
type: number
remote_published:
type: integer
remote_published_pct:
type: number
salary_published:
type: integer
type: object
generated_at:
type: string
live_listings:
type: integer
verification:
properties:
newest_verification:
type: string
oldest_verification:
type: string
reverified_last_24h:
type: integer
reverified_last_24h_pct:
type: number
reverified_last_48h:
type: integer
reverified_last_48h_pct:
type: number
reverified_last_7d:
type: integer
reverified_last_hour:
type: integer
type: object
type: object
type: object
description: Freshness and coverage report.
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
summary: How recently the ledger was verified, and how much of it is inferred
tags:
- Meta
operationId: getV1MetaFreshness
x-operation-id-source: derived
/v1/meta/providers:
get:
description: 'The list `?provider=` and `?exclude_provider=` accept. A value that is
not here is a 422, never a silently empty page — you can always tell
"no jobs match" from "you spelled it wrong".
Only redistributable sources appear. Sources JOA reads but does not
republish are named at `GET /public/coverage` (`excluded_sources`).
Also served without a key at `GET /public/providers`, because "which
sources is this built from" is a question you should be able to answer
before creating an account.'
responses:
'200':
content:
application/json:
schema:
properties:
data:
items:
properties:
label:
type: string
live_listings:
type: integer
provider:
description: The value to pass to ?provider=.
type: string
source_class:
enum:
- employer_ats
- government
- direct
type: string
source_type:
type: string
with_description:
description: 'How many of this source''s live rows carry the
employer''s advert text. Published including the
sources where it is zero — three of them have no
adapter in the description crawl at all, so they
contribute rows and no text, and
`?provider=<that>&require_fields=description` returns
an empty page. Knowing that here beats discovering it
mid-evaluation.
'
type: integer
with_description_pct:
type: number
type: object
type: array
generated_at:
format: date-time
type: string
note:
type: string
type: object
description: The provider list.
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
summary: Every source the ledger is built from, with its live row count
tags:
- Meta
operationId: getV1MetaProviders
x-operation-id-source: derived
components:
responses:
Unprocessable:
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'A parameter we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor. Note the deliberate exceptions, which are clamps rather than errors: `limit` is capped at the plan maximum (a non-numeric value falls back to the default), and comma-separated lists are truncated to the per-parameter maximum shown on each. Unknown query parameters are ignored.
'
Unauthorized:
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: Missing, unknown, revoked or expired key. All four are reported identically.
RateLimited:
content:
application/json:
schema:
$ref: '#/components/schemas/LimitError'
description: 'Over your plan''s per-minute or per-day limit. `Retry-After` says how long (seconds to the next minute boundary, or to UTC midnight on a daily breach). The body says exactly when the limit resets (`resets_at`, `resets_at_text`) and, when a higher plan exists, links a same-key upgrade (`upgrade_url`).
'
headers:
Retry-After:
description: Seconds until the limit resets.
schema:
type: integer
X-RateLimit-Reset:
description: Unix seconds of the next UTC midnight, when the daily counter resets.
schema:
type: integer
schemas:
Error:
properties:
docs:
type: string
error:
type: string
message:
type: string
type: object
LimitError:
allOf:
- $ref: '#/components/schemas/Error'
- properties:
docs_url:
description: The documentation section on this limit.
format: uri
type: string
limit:
description: The limit that was hit, in the unit of `window` (records per month, requests per day or per minute).
format: int64
type: integer
plan:
description: The key's plan, e.g. `explore`.
type: string
resets_at:
description: The exact reset instant, RFC 3339, always UTC.
example: '2026-11-01T00:00:00Z'
format: date-time
type: string
resets_at_text:
description: The same instant in plain English, UTC.
example: 1 November 2026 at 00:00 UTC
type: string
upgrade_plan:
description: The plan `upgrade_url` moves the key to. `growth` for a free (Explore) key or a live trial (the one-click link); otherwise the next plan up. Absent for the top plan and for marketplace keys.
example: growth
type: string
upgrade_url:
description: 'For a free (Explore) key or a live trial that hit the monthly record limit: a one-click link for the SAME key
(`https://jobopportunitiesapi.org/upgrade?k=<signed token>`). Opening it, no sign-in needed, redirects to a
personal Creem checkout that upgrades this key to Growth monthly; same secret, nothing to change in the integration.
The token is signed, scoped to the key and valid for 48 hours. For any other key it is a same-key checkout link for
`upgrade_plan` (the signed-in owner of the key completes it).
'
format: uri
type: string
used:
description: Usage in that window when the limit was hit.
format: int64
type: integer
window:
description: Which limit was hit. `calendar_month_utc` resets on the 1st of the next month at 00:00 UTC; `day_utc` at the next 00:00 UTC; `minute` at the next minute boundary.
enum:
- calendar_month_utc
- day_utc
- minute
type: string
type: object
description: 'The body of a 402 `record_quota_exhausted` and a 429 `rate_limited`. `error`, `message` and `docs` are the same envelope as every other error; the remaining fields are optional additions. Marketplace channel keys and keys with no higher plan get the reset time but no `upgrade_*` fields.
'
securitySchemes:
bearerAuth:
description: '`Authorization: Bearer YOUR_API_SECRET`'
scheme: bearer
type: http