Jobs
The Jobs endpoint lets you search and retrieve job listings with powerful filtering options.
List Jobs
GET /v1/public/jobs
Returns a paginated list of jobs matching your filters.
curl -H "X-API-Key: $JOBVEN_API_KEY" \
"https://api.jobven.com/v1/public/jobs?skills%5B%5D=react&remoteType%5B%5D=remote&limit=20"
const params = new URLSearchParams({ limit: '20' });
params.append('skills[]', 'react');
params.append('remoteType[]', 'remote');
const response = await fetch(
`https://api.jobven.com/v1/public/jobs?${params}`,
{ headers: { 'X-API-Key': process.env.JOBVEN_API_KEY } }
);
const { data, meta } = await response.json();
import requests
import os
response = requests.get(
'https://api.jobven.com/v1/public/jobs',
headers={'X-API-Key': os.environ['JOBVEN_API_KEY']},
params={'skills[]': 'react', 'remoteType[]': 'remote', 'limit': 20}
)
data = response.json()
Query Parameters
| Parameter | Type | Description |
|---|---|---|
q | string | Search job titles by whole word, with phrase, OR and exclusion syntax. See Searching |
descriptionQuery | string | Search job descriptions for a whole-word phrase. See Searching |
skills[] | string | Filter by skills, OR logic, up to 10 values (e.g., ?skills[]=react&skills[]=typescript) |
industry[] | string | Filter by industry, OR logic, up to 10 values (e.g., ?industry[]=technology_software) |
language | string | Required language (ISO 639-1, e.g., fr) |
location | string | City, region, or country name |
country | string | Country code (ISO 3166-1, e.g., US). Case-insensitive |
state | string | States or regions, comma-separated (e.g., CA,New York,QC). US states and Canadian provinces match by name or code. Case and accents are ignored |
city | string | City names, comma-separated (e.g., San Francisco,Montréal). Case and accents are ignored |
remoteType[] | enum | onsite, remote, hybrid, flexible, OR logic |
experienceLevel[] | enum | entry, mid, senior, lead, executive, OR logic |
employmentType[] | enum | full_time, part_time, contract, internship, seasonal, volunteer, temporary, per_diem, other, OR logic |
minSalary | number | Yearly pay of at least this amount (salary.annualMax or annualMin); hourly, weekly and monthly pay is compared per year |
maxSalary | number | Yearly pay starting at or below this amount (salary.annualMin) |
salaryCurrency | string | ISO 4217 code (e.g. USD, EUR, PLN). Jobs paying in that currency; with minSalary or maxSalary, the amounts are in it |
hasVisaSponsorship | boolean | true: postings that offer visa sponsorship. false: postings that say they do not. A posting that does not mention it matches neither |
hasRelocation | boolean | true: postings that offer relocation assistance. false: postings that say they do not. A posting that does not mention it matches neither |
status | enum | active (default), closed. pending is deprecated: it is still accepted and matches no jobs |
listedAfter | number | Jobs listed at or after this time, by listedAt (Unix seconds). Use it to sync: see Sync Only New Jobs |
postedAfter | number | Jobs posted at or after this time, by the employer's posting date (Unix seconds) |
postedBefore | number | Jobs posted at or before this time, by the employer's posting date (Unix seconds) |
cursor | string | Pagination cursor from previous response |
limit | number | Results per page. Defaults to your tier's maximum; see Page Size Limits |
includeTotal | boolean | Return meta.total, the exact number of matching jobs. Off by default, and slower when on. See Getting a total count |
sortBy | enum | postedAt (default), listedAt, title, company. company sorts by the job's primary company name; a job with several companies still lists them all |
order | enum | ASC, DESC (default) |
descriptionFormat | enum | html (default), plain, markdown, all. See Description Formats |
listedAt you hold and send it back as listedAfter, minus a few minutes. See Sync Only New Jobs.Array Parameters
To filter by several values, repeat the parameter, with or without brackets. One value works either way (skills=react or skills[]=react):
GET /v1/public/jobs?skills[]=react&skills[]=typescript&remoteType[]=remote&remoteType[]=hybrid
Searching
Two parameters search text. Everything each one supports:
q | descriptionQuery | |
|---|---|---|
| Searches | Job title | Job description, as plain text (never the HTML markup) |
| Matching | Whole words, in any order | One whole-word phrase, words in the order given |
| Case | Ignored | Ignored |
| Accents | Must match: ingenieur does not find "Ingénieur" | Must match |
| Partial words | No: kubern does not find "Kubernetes" | No |
"exact phrase" | Supported | Optional: the value is already a phrase, so "distributed systems" and distributed systems are the same |
OR | Supported: gtm OR enablement | Not supported: gtm OR enablement finds only that exact phrase |
-word to exclude | Supported: engineer -intern | Not supported: -intern finds the text "-intern" |
| Dotted or hyphenated words | Split into parts: node finds "Node.js", full stack finds "Full-Stack" | node finds "Node.js"; full stack and full-stack each find only their own spelling |
| Symbols | Ignored: c++ searches for "c" | Matched as written: c++ finds "C++" |
| Maximum length | 200 characters | 200 characters |
Nothing to search (-, "") | 400 Bad Request | "" is no filter; other symbols are matched as text |
| Blank value | No filter | No filter |
| Speed | Fast | Slower: pair with postedAfter |
q examples:
| You send | Matches |
|---|---|
backend engineer | Titles containing both words, in any order: "Senior Backend Engineer", "Engineer, Backend" |
"marketing coordinator" | The exact phrase, words in that order |
gtm OR enablement | Titles containing either word |
engineer -intern | Titles with "engineer" but not "intern" |
ai | "AI Engineer" and "Head of AI/ML", but not "Maintenance Technician" |
Both can be combined with each other and with every other filter:
GET /v1/public/jobs?q=backend%20engineer&descriptionQuery=distributed%20systems&postedAfter=1789000000
URL-encode values. A bare + in a query string means a space, so send c++ as descriptionQuery=c%2B%2B.
If a search takes too long to run, the API returns 422 Unprocessable Entity with a message asking you to narrow it. Add postedAfter, or make q or descriptionQuery more specific, and try again.
Description Formats
Job descriptions are stored as sanitized HTML. Use descriptionFormat to request the format your pipeline needs: HTML, plain text, markdown, or all three. All formats are precomputed at ingest time, so responses stay fast even when you request all.
| Value | Fields populated | Use case |
|---|---|---|
html (default) | description | Rendering in web UIs; backwards-compatible for existing integrations |
plain | descriptionPlain | Search indexing, email digests, previews |
markdown | descriptionMarkdown | Markdown-native UIs, LLM / RAG pipelines, static site generators |
all | description, descriptionPlain, descriptionMarkdown | Dual rendering, A/B comparisons |
When you request plain or markdown, the HTML description field is omitted, so you only get the format you asked for. all returns every format.
GET /v1/public/jobs?descriptionFormat=markdown
What the HTML contains
The description HTML is structural: paragraphs, headings, lists, tables, emphasis and links. Everything the source site added for its own rendering is removed: scripts, styles, media, form controls, comments, framework attributes, and empty elements. The only attributes kept are href and title on links (an href must be http, https or mailto), and colspan, rowspan and start where they carry structure. Entities are encoded (&, ).
Links carry no target or rel, so they open in the same tab unless you decide otherwise. To open every link in a new tab, add the attributes when you render:
container.querySelectorAll('a').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener';
});
If you sanitize the HTML before rendering (DOMPurify, sanitize-html), do this in a post-sanitize hook, because most sanitizers strip target by default.
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Senior React Developer",
"descriptionMarkdown": "## About the role\n\nWe are looking for...\n\n- 5+ years React\n- TypeScript fluency"
}
]
}
descriptionPlain uses html-to-text; descriptionMarkdown uses Turndown with ATX headings, - bullets, and fenced code blocks. Output is stable across requests.Response
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Senior React Developer",
"description": "<p>We are looking for...</p>",
"summary": "Build modern web applications with React",
"locations": [
{
"addressLocality": "San Francisco",
"addressRegion": "California",
"addressCountry": "US",
"workLocation": "hybrid"
}
],
"remoteType": "hybrid",
"skills": {
"primarySkills": ["React", "TypeScript"],
"secondarySkills": ["Node.js", "PostgreSQL"],
"softSkills": ["Communication", "Teamwork"]
},
"salary": {
"min": 150000,
"max": 200000,
"currency": "USD",
"period": "annual",
"annualMin": 150000,
"annualMax": 200000
},
"benefits": ["health_insurance", "pensions_401k", "remote_work_hybrid_work_options"],
"experienceLevel": "senior",
"employmentType": "full_time",
"minYearsExperience": 5,
"educationRequirement": {
"required": ["Bachelor's degree"],
"preferred": ["Master's degree"]
},
"industry": ["technology_software"],
"inLanguage": ["en"],
"hasVisaSponsorship": true,
"hasRelocation": false,
"requiresTravel": false,
"postedAt": 1705392000,
"expiresAt": 1708070400,
"applyUrl": "https://company.com/apply/123",
"status": "active",
"companies": [
{
"id": 1042,
"name": "TechCorp Inc.",
"website": "https://techcorp.com"
}
]
}
],
"meta": {
"count": 20,
"nextCursor": "eyJpZCI6IjEyMyIsInZhbHVlIjoxNzA1MzkyMDAwMDAwfQ==",
"hasMore": true,
"requestTimeMs": 45,
"lastUpdatedAt": "2025-01-15T12:00:00Z"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique job identifier (UUID) |
title | string | Job title as the employer wrote it, with repeated whitespace collapsed |
description | string | Full job description (sanitized HTML). Omitted when descriptionFormat=plain or descriptionFormat=markdown |
descriptionPlain | string | Plain-text description. Returned when descriptionFormat=plain or descriptionFormat=all |
descriptionMarkdown | string | Markdown description. Returned when descriptionFormat=markdown or descriptionFormat=all |
summary | string | Brief summary |
locations | array | Location objects (see below) |
remoteType | string | onsite, remote, hybrid, flexible |
skills | object | { primarySkills, secondarySkills, softSkills }, see Skills Object. The snake_case names (primary_skills and the others) are deprecated and served until December 15, 2026 |
salary | object | { min, max, currency, period, annualMin, annualMax }, see Salary Object |
benefits | string | List of benefits |
experienceLevel | string | entry, mid, senior, lead, executive |
employmentType | string | full_time, part_time, contract, etc. |
minYearsExperience | number | Minimum years required. 0 means the posting asks for no experience |
educationRequirement | object | { required, preferred } |
industry | string | Industry categories |
inLanguage | string | Required languages (ISO 639-1) |
hasVisaSponsorship | boolean | true if the posting offers visa sponsorship, false if it says it does not, null if it does not say |
hasRelocation | boolean | true if the posting offers relocation assistance, false if it says it does not, null if it does not say |
requiresTravel | boolean | true if the posting requires travel, false if it says it does not, null if it does not say |
travelPercentage | number | Required travel as percentage (0-100) |
postedAt | number | When the employer posted the job (Unix seconds). Jobven adds a posting when this falls within a year of the day Jobven first finds it, or when the employer gives no date |
expiresAt | number | The employer's closing date, when the posting states one (Unix seconds) |
listedAt | number | When Jobven made the job available (Unix seconds), and again when a closed job reopens |
closedAt | number | null | When Jobven closed the job (Unix seconds), about 30 days after the role was last listed. null while the job is open |
applyUrl | string | URL to apply |
status | string | active: the role is being advertised. closed: the employer's board no longer lists it |
companies | array | [{ id, name, website }]. id gets the company's profile from GET /v1/public/company-profiles/{id} |
salary and postedAt are taken from the posting as written. Others, including experienceLevel, employmentType, minYearsExperience and industry, are AI-enriched and may be inferred when the posting does not state them. See Extracted vs AI-Enriched Fields.Location Object
| Field | Type | Description |
|---|---|---|
streetAddress | string | Street address |
addressLocality | string | City |
addressRegion | string | State/Province |
postalCode | string | Postal/ZIP code |
addressCountry | string | Country (ISO 3166-1 alpha-2), or XX. May be absent. |
workLocation | string | onsite, remote, hybrid, flexible |
XX means the job carries no location information at all, typically "work from anywhere" positions. A location with a city but no addressCountry is one whose country we couldn't identify, and it matches no country filter. See Data Types for details.Getting a total count
meta.total is not returned by default. Pass includeTotal=true when you need it:
GET /v1/public/jobs?country=US&includeTotal=true
Counting every matching job is the slowest part of a request, and it gets slower as the dataset grows, so it is opt-in rather than a cost every caller pays. Most integrations never need it: to page through results you use hasMore and nextCursor, and to show progress you count what you have received.
When you do request it, the number is exact for your filters, and may be up to five minutes old. If counting takes too long, meta.total is left out and you still get the page.
Meta Object
| Field | Type | Description |
|---|---|---|
total | number | Exact number of matching jobs. Only present when you pass includeTotal=true |
count | number | Jobs in this response |
nextCursor | string | Cursor for next page (null if no more) |
hasMore | boolean | More results exist |
requestTimeMs | number | Response time in milliseconds |
lastUpdatedAt | string | When dataset was last updated (ISO 8601) |
Handling Null Values
Most job fields are optional and may be null. Guaranteed fields: id, title, status, companies, and the description field matching your requested format: description when descriptionFormat=html (default) or all, descriptionPlain when plain, descriptionMarkdown when markdown.
// Safe access pattern
const salary = job.salary?.min
? `${job.salary.min.toLocaleString()} ${job.salary.currency ?? ''}+`
: 'Not specified';
const skills = job.skills?.primarySkills?.join(', ') || 'Not listed';
See Data Types - Handling Null Values for complete guidance.
Get Job by ID
GET /v1/public/jobs/{id}
Retrieve a single job by its UUID.
curl -H "X-API-Key: $JOBVEN_API_KEY" \
"https://api.jobven.com/v1/public/jobs/550e8400-e29b-41d4-a716-446655440000"
const response = await fetch(
'https://api.jobven.com/v1/public/jobs/550e8400-e29b-41d4-a716-446655440000',
{ headers: { 'X-API-Key': process.env.JOBVEN_API_KEY } }
);
const job = await response.json();
response = requests.get(
'https://api.jobven.com/v1/public/jobs/550e8400-e29b-41d4-a716-446655440000',
headers={'X-API-Key': os.environ['JOBVEN_API_KEY']}
)
job = response.json()
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Job UUID |
Response
Returns a single job object (same schema as list response items).
Errors
| Code | Description |
|---|---|
404 | Job not found |
Pagination
Jobs use cursor-based pagination for efficient traversal of large datasets.
How It Works
- Make your first request without a
cursor - Use
meta.nextCursorfrom the response for the next page - Continue until
meta.hasMoreisfalse - Keep every other parameter the same between pages: filters,
sortByandorder. A cursor marks a position in one listing.
let cursor = null;
const allJobs = [];
do {
const url = new URL('https://api.jobven.com/v1/public/jobs');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url, {
headers: { 'X-API-Key': process.env.JOBVEN_API_KEY }
});
const { data, meta } = await response.json();
allJobs.push(...data);
cursor = meta.nextCursor;
} while (cursor);
console.log(`Fetched ${allJobs.length} jobs`);
A cursor that can't be decoded returns 400. Start the walk again without one.
Page Size Limits
| Tier | Max per page | Default (when limit is omitted) |
|---|---|---|
| Free | 25 | 25 |
| Starter | 50 | 50 |
| Growth+ | 100 | 100 |
A limit above your tier's maximum is clamped to it, not rejected. A limit of 0 or below, or a non-integer, returns 400.
See also
- Webhooks: push the same job lifecycle events (
job.created,job.updated,job.closed,job.deleted) to your endpoint in real time instead of polling.