Job Opportunities API

Check the data. Then trust it.

endpoints

Companies

The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable.

Last verified 2026-08-22 · Assumes: Authentication. · Markdown copy

1GET /v1/companies

Employers whose live roles this API returns. Every company here has at least one row you can actually fetch.

In depthWhy it exists, what it is not, what people get wrong

The guarantee that makes this endpoint worth having: open_roles and GET /v1/jobs?company=<slug> reconcile. A directory entry advertising roles the API will not serve is a broken promise, so open_roles counts only rows you can retrieve.

There is a second population behind include_discovered=true: companies we watched hiring on their own careers page under a source we do not redistribute. They arrive with open_roles: 0, a non-zero own_site_roles and a careers_url pointing at the page we saw them on. They are evidence a company is hiring, not inventory you can buy: useful for enrichment, misleading if you treat them as listings.

Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251 to 200
offsetinteger0 to 100000
cursorstring

The next_cursor from the previous page. Overrides offset.

countrystring
org_typestring
source_typestring
has_websiteboolean

Only companies whose domain we fetched and found their own name on. An absent website means we could not prove one, not that they have none.

include_discoveredboolean

Also return the companies we watched hiring on their own careers page under a source we do not redistribute. They arrive with open_roles: 0 and a non-zero own_site_roles, and careers_url points at the page we saw them on. Useful for enrichment; not inventory.

qstring

9 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.

CodeWhat it means
200A page of companies.
400A parameter the endpoint does not take. unknown_parameter: the name is not a parameter of this endpoint; the message names it and, when a valid parameter is within a small edit distance, suggests it ("unknown parameter: 'countyr'; did you mean 'country'?"). unsupported_parameter: a real parameter of another endpoint that has no meaning here (for example limit on /v1/export). Refused before the meter: nothing is charged.
401Missing, unknown, revoked or expired key. All four are reported identically.
402The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries X-RateLimit-Records-Remaining and X-RateLimit-Records-Reset (the reset instant, Unix seconds), so this is visible long before it happens.
422A parameter value we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor, a limit outside 1-200 or not a whole number (bad_limit), a flag that is not true/false (bad_boolean), a comma-separated list longer than its maximum (too_many_values), a text filter the query grammar cannot honour (query_syntax: an unmatched parenthesis, an empty group, an OR with no term on one side, a bare AND or NOT, since 2026-10-25). Nothing is charged. The one visible reduction: a limit within 1-200 but above the plan's page cap is reduced to the cap and flagged in X-JOA-Limit-Applied. Unknown parameter NAMES are a 400 (BadRequest), never ignored.
429Over 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).

Ordered by GREATEST(open_roles, own_site_roles) DESC, name, slug. The slug on the end is what makes the ordering total: without it, tie groups covering about a fifth of all companies could be reordered between two requests, and offset paging then silently repeated and skipped rows.

The largest employers in the ledger
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/companies?limit=3' \
  | jq -r '.data[] | "\(.open_roles)\t\(.name)\t\(.website // "—")"'
{
  "data": [
    {
      "slug": "kroger",
      "name": "Kroger",
      "country": "US",
      "sectors": [
        "Retail",
        "Healthcare",
        "Operations & Admin"
      ],
      "open_roles": 36242,
      "own_site_roles": 0,
      "source_types": [
        "career_site"
      ],
      "first_seen": "2026-08-02"
    },
    {
      "slug": "cvs-health-a745a5",
      "name": "CVS Health",
      "country": "US",
      "industry": "Healthcare",
      "org_type": "Enterprise",
      "sectors": [
        "Healthcare",
        "Operations & Admin",
        "Retail"
      ],
      "open_roles": 21004,
… 29 more lines
`/public/companies` answered this keylessly until PUBLIC_LISTINGS_GATE; it now needs a key too (see [Keyless access](/docs/api/keyless)). The response shown is fetched by this page's own server-side rendering, authenticated the same way joa-web's own pages are, not with a key you could reuse. Real response, fetched from /public/companies?limit=3 when this page was built (27 October 2026, 11:29 UTC). Run the command yourself and you will get today's rows, not these.

2GET /v1/companies/{slug}

One employer, by slug. Returns the same object the directory returns, for the one row.

In depthWhy it exists, what it is not, what people get wrong

The slug is the one on a job row's company_slug and in the website's /company/<slug> URLs. A slug that has been RETIRED now answers 301 with the current slug in Location, so use curl -L and follow it. Only a slug that never existed is a 404, and on this endpoint specifically, a 404 is not necessarily permanent. See the next section.

Exact contractTypes, defaults, ranges, errors, edge cases
slugstringrequired

1 parameter, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.

CodeWhat it means
200The company.
400A parameter the endpoint does not take. unknown_parameter: the name is not a parameter of this endpoint; the message names it and, when a valid parameter is within a small edit distance, suggests it ("unknown parameter: 'countyr'; did you mean 'country'?"). unsupported_parameter: a real parameter of another endpoint that has no meaning here (for example limit on /v1/export). Refused before the meter: nothing is charged.
401Missing, unknown, revoked or expired key. All four are reported identically.
402The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries X-RateLimit-Records-Remaining and X-RateLimit-Records-Reset (the reset instant, Unix seconds), so this is visible long before it happens.
404No such row.
429Over 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).
FieldTypeAlways presentMeaning
careers_urlstringno
countrystringno
first_seenstringno
industrystringno
logostringno
namestringno
open_rolesintegernoRoles retrievable from THIS API. Never a number the API cannot honour.
org_typestringno
own_site_rolesintegernoVacancies seen on the company own careers page.
sectorsarrayno
slugstringno
source_typesarraynoaggregator and agency are reserved and match no rows today; see the source_type enum.
websitestringno
website_verifiedstringnoWhen we fetched this domain and found the company own name on it.
One employer
$ curl -s -H "Authorization: Bearer $JOA_KEY" https://api.jobopportunitiesapi.org/v1/companies/but | jq '.data'
{
  "data": {
    "slug": "but",
    "name": "BUT",
    "country": "FR",
    "sectors": [
      "Retail",
      "Operations & Admin",
      "Hospitality"
    ],
    "open_roles": 73,
    "own_site_roles": 0,
    "source_types": [
      "ats"
    ],
    "first_seen": "2026-07-27",
    "closure_history": [
      {
        "slug": "alternance-charge-de-catalogue-produits-marketplace-f-h-x-ea31b6ec",
        "title": "Alternance - Chargé de Catalogue Produits Marketplace F/H/X",
… 61 more lines
`/public/companies/{slug}` answered this keylessly until PUBLIC_LISTINGS_GATE; it now needs a key too, same as the directory above. Real response, fetched from /public/companies/but when this page was built (27 October 2026, 11:29 UTC). Run the command yourself and you will get today's rows, not these.

3Company slugs are not yet guaranteed stable

A company's slug can change between refreshes when duplicate rows compete for the same bare name. Key on company_domain where you have one.

In depthWhy it exists, what it is not, what people get wrong

A slug is derived from the company's display name, and display names improve over time. That is a genuine improvement (an employer whose ATS token was jj becomes johnson-johnson once the real name is resolved), and it moves the slug. Separately, when two company rows resolve to the same bare slug, one keeps it and the other gets a short hash suffix. Which one keeps it is not guaranteed to be the same across refreshes.

You can see both forms in the live data: uncontested names are bare (but), contested ones carry a suffix (stripe-ce7566, docusign-f13edb). The suffix is not a version number and it is not derived from anything you can compute.

Exact contractTypes, defaults, ranges, errors, edge cases
  1. Store company_domain as your key wherever the company has one, and the slug only as a display and link value.
  2. A retired slug answers 301 to the current one; follow the redirect and you are done. On a genuine 404 (a slug that never existed) look the company up again: GET /v1/jobs?company_domain=<domain>&limit=1 gives you the current company_slug from any live row, and GET /v1/companies?q=<name> finds it by name.
  3. Do not cache a slug for longer than you cache the row it came from.
  4. If you publish URLs containing a slug, be prepared to redirect. The website does the same thing for the same reason.
Re-resolve a company that 404s
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"
domain=stripe.com
# Any live row for that domain carries the company's CURRENT slug.
curl -s -H "$H" "$A/v1/jobs?company_domain=$domain&limit=1" \
  | jq -r '.data[0].company_slug'

This is documented as a caveat rather than a guarantee because it is one. When the underlying fix lands, this section will be replaced with a statement of the guarantee and a date. Until then, believe the caveat.

This page was rendered 27 October 2026, 11:29 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.