Every API here is available over the APIs.io API and to AI agents over MCP.
# Спецификация Jobs API для листинга на RapidAPI.
#
# Переписана 10.09.2026 по живому API: каждый пример запроса ниже отправлен
# дословно через листинг с ключом подписчика и вернул 200; схемы и примеры
# ответов сняты с этих ответов (массивы урезаны, description укорочен, прочие
# значения не тронуты). Нормативный контракт — denis/jobs-backend/02-contract.md;
# где он расходится с живым API, здесь записано то, что API отвечает.
#
# Файл публикуется: никаких адресов origin, внутренних хостов и ключей.
openapi: 3.0.3
info:
title: Hiring Index
version: "1.0.0"
description: |
More than a million and a half live job postings, read straight from the
applicant tracking systems companies actually hire on — Workday,
SmartRecruiters, Greenhouse, Workable, Lever, Ashby, Recruitee, Teamtailor,
Breezy, Personio, Hireology, PageUp and Zoho Recruit. Tens of thousands of company career boards behind one
endpoint, and where a posting carries an apply link, it points at the
employer's own ATS form, not at a re-listing.
What this API does that a job board API does not.
**Aggregates, not pagination.** `/jobs/insights` takes the same filter as a
search and returns the whole picture of that slice — salary percentiles per
currency and pay period, top employers, city, country, platform, seniority
and employment-type splits, and posting age — computed server side. One call
answers "what does this market look like", instead of thousands of calls and
a spreadsheet on your side.
**Dates you can audit.** Where the ATS publishes a date, `posted_at` is the
employer's own publication date, and `posted_at_raw` keeps exactly what the
vendor wrote. When a vendor gives only a relative phrase such as
"Posted 30+ Days Ago", `posted_at` is left out rather than guessed.
`first_seen_at`, on every row, is when this index first saw the posting.
**Nothing is filled in for you.** A field the source did not state is absent
from the object — never null, never an empty string, never a default.
servers:
# The public RapidAPI listing host. NOT the origin: the origin is a private address
# configured on the listing's target group and it must never appear in a published spec.
# The paths below carry no /v1 prefix because the listing declares them that way; the
# /v1 lives in the origin URL, where RapidAPI appends the path.
- url: https://hiringindex.p.rapidapi.com
description: RapidAPI listing (send X-RapidAPI-Key and X-RapidAPI-Host)
security:
- rapidApiKey: []
tags:
- name: search
description: Paged job search over a filter
- name: insights
description: Aggregates over the same filter - the thing no one else in the category ships
- name: job
description: A single job posting by id
paths:
# Пути ровно те, что заведены в листинге: search, insights, job. GET /healthz был
# объявлен здесь как запасной вариант T-008 и в кабинет не заливался — на маркетплейсе
# он отдаёт 404 "Endpoint does not exist" (замер джуна 12.09, T-068). Спека с эндпоинтом,
# которого в листинге нет, — это пример, по которому клиент получает 404; убран 12.09.
# Текст на /docs («There is no separate liveness path on the listing») этим остаётся правдой.
/jobs/search:
post:
tags: [search]
operationId: searchJobs
summary: Search job postings
description: |
Returns a page of postings matching the filter. The request body is the
filter object itself, at the top level — there is no wrapper. Send `{}` to
match everything; a request with no body at all is rejected with `400`.
All filters are optional; an absent key and an empty array mean the same
thing — the filter is not applied. An unknown key is rejected with `422`
and the list of valid keys, so a typo never returns a silently wrong slice.
`job_titles` matches the posting title only. `keywords` matches the title
or the description, and several keywords are matched as OR, not AND: a
request for `["Kubernetes", "Terraform"]` returns postings mentioning
either. A keyword found in more than about 130,000 postings returns
`422 keyword_too_common` in milliseconds instead of a timeout; narrowing by
city or country does not help, so use `job_titles` for the role or a rarer
term.
`page` and `limit` both run from 1 to 100, so one filter yields at most
10,000 rows. When a filter matches more than that window, `total_pages`
stops at 100 and `pages_truncated` says how many rows are reachable. One
page past the end is an empty `200`, not an error.
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SearchRequest' }
examples:
remoteDataEngineers:
summary: Remote data engineering roles posted in the last week
value:
job_titles: ["Data Engineer"]
remote_flag: ["true"]
days_ago: 7
limit: 20
berlinYearlyPay:
summary: Berlin roles whose advertised yearly pay reaches €60,000
value:
cities: ["Berlin"]
salary: { min: 60000, period: ["year"], currency: ["EUR"], match: overlaps }
limit: 20
responses:
'200':
description: Page of postings
content:
application/json:
schema: { $ref: '#/components/schemas/SearchResponse' }
examples:
berlinYearlyPay:
summary: Response to berlinYearlyPay, captured 2026-09-10 (two rows shown, description shortened)
value:
jobs:
- _id: YXNoYnl8YWxtZWRpYXwxOGM1YmNhOS01MmM5LTRkZWMtOGJmYi1jNmUyNzZkYWNlZGF8MA
source_platform: ashby
handle: almedia
source_job_id: 18c5bca9-52c9-4dec-8bfb-c6e276daceda
record_index: 0
title: Senior Growth Manager (Berlin)
company_name: Almedia
description: <p style="min-height:1.5em">This isn’t your regular job. Almedia is a place where those wh…
posted_at: '2026-09-09T14:43:52.483+00:00'
posted_at_raw: '2026-09-09T14:43:52.483+00:00'
posting_url: https://jobs.ashbyhq.com/almedia/18c5bca9-52c9-4dec-8bfb-c6e276daceda
apply_url: https://jobs.ashbyhq.com/almedia/18c5bca9-52c9-4dec-8bfb-c6e276daceda/application
location_raw: Berlin
city: Berlin
region: Berlin
country: Germany
country_code: DE
postal_code: '10117'
remote_flag: 'false'
employment_type: FullTime
department: Growth
team: Advertiser Growth
salary_text: €75K – €100K • Offers Equity • Offers Bonus
salary:
min: 75000
max: 100000
currency: EUR
period: 1 YEAR
source: vendor_field
confidence: stated
first_seen_at: '2026-09-10T02:50:23.085014+00:00'
fetched_at: '2026-09-10T02:50:23.085014+00:00'
content_hash: daca92c9fc8dc1ee05fc31644fb8e25c7b63503861d3a4475421e7113bb7048d
- _id: YXNoYnl8YWxtZWRpYXw5MGRiZjE3Yi1mNzA5LTRjZmMtOTc3NS04ZjY0MmQ5MzFkNTh8MA
source_platform: ashby
handle: almedia
source_job_id: 90dbf17b-f709-4cfc-9775-8f642d931d58
record_index: 0
title: Founding Engineer
company_name: Almedia
description: <p style="min-height:1.5em">This isn’t your regular job. Almedia is a place where those wh…
posted_at: '2026-09-09T13:25:49.087+00:00'
posted_at_raw: '2026-09-09T13:25:49.087+00:00'
posting_url: https://jobs.ashbyhq.com/almedia/90dbf17b-f709-4cfc-9775-8f642d931d58
apply_url: https://jobs.ashbyhq.com/almedia/90dbf17b-f709-4cfc-9775-8f642d931d58/application
location_raw: Berlin
city: Berlin
region: Berlin
country: Germany
country_code: DE
postal_code: '10117'
remote_flag: 'false'
employment_type: FullTime
department: Tech
team: Engineering
salary_text: €90K – €150K • Offers Equity
salary:
min: 90000
max: 150000
currency: EUR
period: 1 YEAR
source: vendor_field
confidence: stated
first_seen_at: '2026-09-03T23:12:31.801589+00:00'
fetched_at: '2026-09-10T02:50:23.085014+00:00'
content_hash: 960fae4b642c8094056a1835f710da4a3c9719889a3e4a603db520bfc1c3dc86
total_count: 207
company_count: 33
page: 1
limit: 20
total_pages: 11
meta:
request_id: be8374ea54b3756c61e55409f09e8075
took_ms: 62.35
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
'503': { $ref: '#/components/responses/Busy' }
/jobs/insights:
post:
tags: [insights]
operationId: jobInsights
summary: Aggregates over a filtered slice
description: |
Takes the same filter as `/jobs/search` — without `page` and `limit`,
which it rejects — and returns aggregates over the whole matching set, not
over one page.
- `headline`: rows and distinct postings matched, named employers, how many
rows carry a salary, a publication date and a description, and postings
first seen in the last seven days.
- `salary`: percentiles of the lower and the upper end of the advertised
band, one entry per currency and pay period as the employer wrote them,
so hourly, monthly and yearly pay are never averaged into one number. An
entry needs at least 30 disclosed salaries: a narrow slice returns an
empty array rather than a median of five rows.
- `freshness` and `posted_at`: posting age in days, counted from the
employer's publication date.
- `top_companies` and the `*_split` lists: counts by employer, city,
country code, ATS platform, board handle, seniority, employment type and
remote flag. Split values are the vendor's own strings, so `Full-time`
and `FullTime` arrive as separate rows.
This is the call that replaces a data pipeline. Ask about Software
Engineers in San Francisco and get the pay band, who is hiring and how old
the listings are — in one round trip, billed as one call.
**Grouped mode.** Add `group_by` together with a `cities` or
`country_codes` list and the answer comes back as one block per city or
country in `groups[]` — one call instead of one per location.
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/InsightsRequest' }
examples:
sfSoftwareEngineers:
summary: What the Software Engineer market looks like in San Francisco
value:
job_titles: ["Software Engineer"]
cities: ["San Francisco"]
berlinDataEngineers:
summary: Data Engineers in Berlin
value:
job_titles: ["Data Engineer"]
cities: ["Berlin"]
byCountry:
summary: Data Engineers in Germany and the Netherlands, one block per country
value:
job_titles: ["Data Engineer"]
country_codes: ["DE", "NL"]
group_by: country_code
percentiles: true
responses:
'202':
description: |
A filter that is not cached yet returns 202 with `Retry-After: 30`; call again
after the delay to get the full aggregates (`meta.computed_at`). Each call,
including the 202, counts as one request against your plan.
This replaces the deadline `503` that `/jobs/insights` used to return: after
T-072 that path no longer exists here. `503 Busy` still applies to both
endpoints.
headers:
Retry-After:
description: Seconds to wait before repeating the call. A hint, not a guarantee.
schema: { type: integer, example: 30 }
content:
application/json:
schema:
type: object
required: [status, message]
properties:
status: { type: string, example: computing }
message: { type: string }
retry_after_seconds: { type: integer, example: 30 }
meta: { type: object }
example:
status: computing
message: this filter has not been computed yet. The work has started; ask again in about 30 seconds and the answer will be here.
retry_after_seconds: 30
meta:
request_id: 9bb8feb8f661bb27d7e73f0e6a3f3512
took_ms: 1.65
'200':
description: |
Aggregates. Without `group_by` the blocks sit at the top level; with it
the response is `group_by` plus `groups[]`, one set of blocks per group.
content:
application/json:
schema: { $ref: '#/components/schemas/InsightsResponse' }
examples:
sfSoftwareEngineers:
summary: Response to sfSoftwareEngineers, captured 2026-09-10 (lists trimmed to three)
value:
headline:
row_count: 1053
job_count: 1047
company_count: 300
with_salary: 643
with_posted_at: 1053
with_description: 1004
with_detail: 46
new_this_week: 59
salary:
- currency: USD
period: 1 YEAR
count: 635
min:
p25: 168000
p50: 200000
p75: 230000
p90: 266000
max:
p25: 250000
p50: 300000
p75: 390000
p90: 445000
posted_at:
count: 1053
days_since_posted:
p25: 36
p50: 98
p75: 225
freshness:
count: 1053
median_days_live: 98
pct_last_7_days: 5.6
pct_over_60_days: 63.1
top_companies:
- company_name: OpenAI
count: 124
- company_name: Crusoe
count: 38
- company_name: Harvey
count: 31
city_split:
- value: San Francisco
count: 1053
country_code_split:
- value: US
count: 991
- value: us
count: 46
employment_type_split:
- value: FullTime
count: 966
- value: Full-time
count: 62
- value: Intern
count: 13
remote_flag_split:
- value: 'false'
count: 477
- value: 'true'
count: 418
seniority_split:
- value: Mid-Senior Level
count: 37
- value: Mid-Senior level
count: 11
- value: Entry Level
count: 7
platform_split:
- value: ashby
count: 980
- value: smartrecruiters
count: 46
- value: workable
count: 24
handle_split:
- value: openai
count: 125
- value: crusoe
count: 38
- value: harvey
count: 31
meta:
request_id: 1e36fe5edd51b40581624be307ac5d45
took_ms: 714.67
byCountry:
summary: Response to byCountry, captured 2026-09-10 (one group shown, lists trimmed to three)
value:
group_by: country_code
groups:
- country_code: nl
row_count: 95
with_salary: 34
new_this_week: 17
with_posted_at: 95
with_description: 66
with_detail: 28
top_companies:
- value: Sia
count: 16
- value: Metyis AG
count: 5
- value: Sopra Steria
count: 5
city_split:
- value: amsterdam
count: 25
- value: rotterdam
count: 14
- value: maastricht
count: 8
country_code_split:
- value: nl
count: 95
employment_type_split:
- value: fulltime_fixed_term
count: 32
- value: Full-time
count: 31
- value: fulltime_permanent
count: 25
remote_flag_split:
- value: 'false'
count: 88
- value: 'true'
count: 7
seniority_split:
- value: mid_level
count: 29
- value: Mid-Senior Level
count: 27
- value: experienced
count: 25
platform_split:
- value: recruitee
count: 61
- value: smartrecruiters
count: 28
- value: workable
count: 4
freshness:
count: 95
median_days_live: 62
pct_last_7_days: 17.9
pct_over_60_days: 50.5
p25: 14.5
p75: 226
p90: 517.6
salary:
- count: 32
currency: EUR
period: month
min:
p25: 3500
p50: 4000
p75: 5000
p90: 5500
meta:
request_id: 4e9c60b413cf0442261e8071b9eb49ed
took_ms: 1010.13
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
'503': { $ref: '#/components/responses/Busy' }
/jobs/{id}:
get:
tags: [job]
operationId: getJob
summary: One posting by id
description: |
The same object a search returns for that posting, including the
description where the source publishes one.
parameters:
- name: id
in: path
required: true
schema: { type: string }
description: Value of `_id` from a search response, used verbatim
example: YXNoYnl8YWxtZWRpYXwxOGM1YmNhOS01MmM5LTRkZWMtOGJmYi1jNmUyNzZkYWNlZGF8MA
responses:
'200':
description: The posting
content:
application/json:
schema: { $ref: '#/components/schemas/Job' }
examples:
almedia:
summary: Captured 2026-09-10 (description shortened)
value:
_id: YXNoYnl8YWxtZWRpYXwxOGM1YmNhOS01MmM5LTRkZWMtOGJmYi1jNmUyNzZkYWNlZGF8MA
source_platform: ashby
handle: almedia
source_job_id: 18c5bca9-52c9-4dec-8bfb-c6e276daceda
record_index: 0
title: Senior Growth Manager (Berlin)
company_name: Almedia
description: <p style="min-height:1.5em">This isn’t your regular job. Almedia is a place where those wh…
posted_at: '2026-09-09T14:43:52.483+00:00'
posted_at_raw: '2026-09-09T14:43:52.483+00:00'
posting_url: https://jobs.ashbyhq.com/almedia/18c5bca9-52c9-4dec-8bfb-c6e276daceda
apply_url: https://jobs.ashbyhq.com/almedia/18c5bca9-52c9-4dec-8bfb-c6e276daceda/application
location_raw: Berlin
city: Berlin
region: Berlin
country: Germany
country_code: DE
postal_code: '10117'
remote_flag: 'false'
employment_type: FullTime
department: Growth
team: Advertiser Growth
salary_text: €75K – €100K • Offers Equity • Offers Bonus
salary:
min: 75000
max: 100000
currency: EUR
period: 1 YEAR
source: vendor_field
confidence: stated
first_seen_at: '2026-09-10T02:50:23.085014+00:00'
fetched_at: '2026-09-10T02:50:23.085014+00:00'
content_hash: daca92c9fc8dc1ee05fc31644fb8e25c7b63503861d3a4475421e7113bb7048d
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404':
description: No posting with that id
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error: not_found
message: that is not an id this API issued. An _id comes from a search response and is used verbatim.
meta:
request_id: 86ae6ea2dab6b6f5f07e54e1af0b97e0
took_ms: 0.27
'429': { $ref: '#/components/responses/RateLimited' }
'503': { $ref: '#/components/responses/Busy' }
components:
securitySchemes:
rapidApiKey:
type: apiKey
in: header
name: x-rapidapi-key
responses:
BadRequest:
description: No body, or a body that is not a JSON object
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error: invalid_request
message: the request has no body. Send a JSON object; {} is the filter that matches everything.
meta:
request_id: 7930fd0adc27ed3a6cde6a53ce338d2c
took_ms: 0.07
Unauthorized:
description: |
Missing or invalid key. Answered by the marketplace before the request
reaches this API, in its own shape — `message` only, no `error`.
content:
application/json:
schema: { $ref: '#/components/schemas/MarketplaceError' }
Forbidden:
description: |
A valid key without a subscription to this API. Answered by the
marketplace, `message` only.
content:
application/json:
schema: { $ref: '#/components/schemas/MarketplaceError' }
example:
message: You are not subscribed to this API.
Unprocessable:
description: |
The filter is well-formed JSON but cannot be run. `error` is
`invalid_request` (an unknown key or a malformed value — `message` names
it and lists the valid keys) or `keyword_too_common` (a `keywords` term
matches too many postings — `meta.estimated_matches` and `meta.limit` say
by how much).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
unknownKey:
summary: A key the API does not have
value:
error: invalid_request
message: '''location_types'' is not a filter key. The keys are: cities, company_name, country_codes, days_ago, employment_type, handles, job_titles, keywords, limit, page, remote_flag, salary, seniority, source_platforms'
meta:
request_id: de1bb62e86aa15134ec156b64dca09b8
took_ms: 0.16
keywordTooCommon:
summary: '{"keywords": ["experience"]}'
value:
error: keyword_too_common
message: '''experience'' appears in about 64% of postings (~1,123,068 matches); keyword aggregates are computed for terms under 130,000 matches. Search the role with job_titles, or use a rarer keyword -- narrowing by city, country or employment type does not help: the term scan comes first.'
meta:
term: experience
estimated_matches: 1123068
limit: 130000
request_id: 32c3863fafb59454773a444e7e782c7f
took_ms: 1.48
RateLimited:
description: Plan quota or rate limit exceeded. Answered by the marketplace; see its rate-limit headers.
content:
application/json:
schema: { $ref: '#/components/schemas/MarketplaceError' }
Busy:
description: Capacity exhausted for the moment (`busy`), or the filter did not finish inside the statement deadline (`timeout`). Retry after `retry_after_seconds`, or narrow the filter.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
schemas:
Meta:
type: object
description: Present on every response of this API.
properties:
request_id:
type: string
description: Quote it when you contact support.
example: be8374ea54b3756c61e55409f09e8075
took_ms:
type: number
description: Server time in milliseconds — tells our part from the network's.
example: 62.35
Error:
type: object
description: Every error this API itself returns. Branch on `error`, not on `message`.
required: [error, message]
properties:
error:
type: string
description: Short code for the client to branch on.
example: invalid_request
enum: [invalid_request, keyword_too_common, not_found, busy, timeout]
message:
type: string
description: Human-readable explanation; wording may change.
retry_after_seconds:
type: integer
description: '`busy` and `timeout` only: how long to wait before retrying — 1 second for `busy`, 2 for `timeout`.'
meta:
type: object
properties:
request_id: { type: string }
took_ms: { type: number }
term:
type: string
description: '`keyword_too_common` only: the keyword that was refused.'
estimated_matches:
type: integer
description: '`keyword_too_common` only: roughly how many postings mention it.'
limit:
type: integer
description: '`keyword_too_common` only: the ceiling a keyword must stay under.'
MarketplaceError:
type: object
description: Shape of 401, 403 and 429, which the marketplace answers before the request reaches this API.
properties:
message: { type: string }
SalaryFilter:
type: object
description: |
Filter on the advertised pay band; only postings that disclose a salary
can match. Amounts are never converted between currencies: `min` and `max`
are compared with the band in the currency the posting advertises. Send
`currency` to keep the comparison in one currency — without it a 60,000
bound matches 60,000 euros, dollars and rupees alike.
properties:
min:
type: number
example: 60000
max:
type: number
example: 90000
currency:
type: array
items: { type: string }
description: |
ISO 4217 codes, any case. Keeps only postings advertised in these
currencies. Nothing is converted.
example: ["EUR"]
period:
type: array
items: { type: string }
description: |
Pay period, as an array: `year`, `month`, `week`, `day` or `hour`. Always
send it: without it a yearly bound is compared with hourly and monthly
rates as bare numbers. Vendor spellings read from a response (`1 YEAR`,
`bi-week-salary`, `one-time`) are accepted too; anything else is rejected
with 422 `invalid_request`, and the message lists what is accepted.
example: ["year"]
match:
type: string
enum: [contains, overlaps]
default: contains
description: |
How a posting's band has to meet your bounds. `contains` keeps only
postings whose whole band sits inside them; `overlaps` keeps every
posting whose band touches them — what a question like "which roles
can pay me this" means.
JobFilter:
type: object
description: |
The filter `/jobs/search` and `/jobs/insights` share. Sent as the request
body itself. An absent key and an empty array both mean "not applied";
an unknown key is rejected with `422`. String lists match any of their
values (OR).
properties:
job_titles:
type: array
items: { type: string, minLength: 3 }
description: |
# --- truncated at 32 KB (50 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/hiring-index/refs/heads/main/openapi/hiring-index-openapi.yaml