endpoints
Companies
The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable.
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 200offsetinteger0 to 100000cursorstringThe next_cursor from the previous page. Overrides offset.
countrystringorg_typestringsource_typestringhas_websitebooleanOnly 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_discoveredbooleanAlso 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.
qstring9 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.
| Code | What it means |
|---|---|
200 | A page of companies. |
400 | A 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. |
401 | Missing, unknown, revoked or expired key. All four are reported identically. |
402 | The 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. |
422 | A 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. |
429 | 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). |
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.
$ 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
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
slugstringrequired1 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.
| Code | What it means |
|---|---|
200 | The company. |
400 | A 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. |
401 | Missing, unknown, revoked or expired key. All four are reported identically. |
402 | The 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. |
404 | No such row. |
429 | 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). |
| Field | Type | Always present | Meaning |
|---|---|---|---|
careers_url | string | no | |
country | string | no | |
first_seen | string | no | |
industry | string | no | |
logo | string | no | |
name | string | no | |
open_roles | integer | no | Roles retrievable from THIS API. Never a number the API cannot honour. |
org_type | string | no | |
own_site_roles | integer | no | Vacancies seen on the company own careers page. |
sectors | array | no | |
slug | string | no | |
source_types | array | no | aggregator and agency are reserved and match no rows today; see the source_type enum. |
website | string | no | |
website_verified | string | no | When we fetched this domain and found the company own name on it. |
$ 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
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
- Store
company_domainas your key wherever the company has one, and the slug only as a display and link value. - 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=1gives you the currentcompany_slugfrom any live row, andGET /v1/companies?q=<name>finds it by name. - Do not cache a slug for longer than you cache the row it came from.
- If you publish URLs containing a slug, be prepared to redirect. The website does the same thing for the same reason.
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.