Avature Jobs API: How to Get Avature Job Listings as Data
TL;DR
Avature has no public JSON jobs API. You page through a tenant's SearchJobs HTML with a jobOffset parameter, about 10 job links per page, then fetch each JobDetail page and read its label/value fields. Job page templates differ by tenant, so field labels and the description container change from one employer to the next, and schema.org JSON-LD is only present on some tenants. Many tenants publish sitemap.xml with a per-job lastmod you can use as a fallback date.
On this page
Avature has no public JSON jobs API: to get Avature jobs as data, page through https://{host}/{basePath}/SearchJobs?jobOffset=0 in steps of 10 to collect job links, then fetch each JobDetail page and parse its fields. The data is public, because it's on the career site, but it's server-rendered HTML, and each employer customizes the templates. That makes Avature a scraping job rather than an API integration, and the parsing has to be generic enough to survive tenants you haven't seen yet.
That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain an Avature scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the Jobs API docs.
At a Glance
| Public JSON API | None |
| Job list | https://{host}/{basePath}/SearchJobs?jobOffset={n}, HTML |
| Job details | HTML job page; JSON-LD on some tenants only |
| API key | None |
| Pagination | jobOffset, 10 job links per page |
| Salary field | None we read |
| Remote | No flag; infer from title, location or work-time text |
| Fallback dates | {basePath}/sitemap.xml lastmod on many tenants |
What Avature Is
Avature is an enterprise recruiting CRM and applicant tracking platform used mostly by large employers, including global logistics groups, retailers, consultancies and manufacturers. Its career sites are heavily customizable, and many of them aren't in English: we've seen French, Dutch, Portuguese and Spanish tenants.
Avature does offer integration APIs to its customers, and they need credentials from the employer's Avature account. For published listings at companies you don't have that relationship with, the career site is the source.
Finding the Host and Base Path
An Avature career site lives on a tenant subdomain such as acme.avature.net, or on the employer's own domain (careers.acme.com). You need two things from it: the host, and the base path that comes before SearchJobs.
https://acme.avature.net/careers/SearchJobs
└── host ──────┘ └ base ┘
The base path isn't predictable. One tenant uses careers, another en_US/jobs, another Careers with a capital C. Copy it exactly from the employer's own careers link, including its case. There's no public directory of Avature customers, so you need each company's address.
Listing Jobs
The search page is HTML. Each job on it is a link whose path contains /JobDetail/{slug}/{id}, and the numeric ID at the end is the job's ID:
curl "https://acme.avature.net/careers/SearchJobs"<a href="https://acme.avature.net/careers/JobDetail/Warehouse-Supervisor/22273">Warehouse Supervisor</a>Match job links by that href pattern, not by CSS classes. Tenants use completely different class names for the results list, but the /JobDetail/ link shape was the same on every tenant we sampled. Some templates repeat the same link for a share or apply icon in the same row, so dedupe by ID and keep the first link that has text, which is the title.
Pagination
Add jobOffset to get the next page. Each page yielded 10 job links in our tests:
curl "https://acme.avature.net/careers/SearchJobs?jobOffset=10"Don't hard-code 10 as a stop condition. Keep advancing the offset and stop when a page gives you no job IDs you haven't already seen. If a tenant uses a different page size, a fixed-stride check silently truncates the list; a "no new IDs" check doesn't.
The Job Page
Fetch each JobDetail URL as-is. Here's where tenants diverge. We've seen three template families:
- Bold labels in paragraphs:
<p><strong>Location:</strong> Warsaw, Poland</p>, with labels such as "Location", "Date published", "Working Time", "Department" and "Ref #". - Label/value divs:
.article__content__view__fieldblocks, each with a__labeland a__valuechild. - Field sets:
.fieldSetblocks with.fieldSetLabeland.fieldSetValue.
Some pages also mark the title and description with itemprop="title" and itemprop="description", and a few wrap the description in .article__content--rich-text. Check those first.
Picking the description
Labels don't help here. Some tenants leave the description's label blank, some call it "Job Description and Requirements", and some use a label in another language with no "description" keyword at all. What worked across every tenant we checked was simpler: the longest field on the page is the description. On one tenant a short unlabeled salary blurb came before the real description, so "first blank label" picked the wrong field; "longest field" didn't. Set a minimum length (around 50 characters works) so a short title or city never wins on a page with no real description.
Picking the location
Location labels vary too: "Location", "Location(s)", "Locations", "Advertising location". Match any label containing "location". Some tenants split it across separate "City", "State" or "Country" fields instead. Keep those pieces separate rather than joining them into one string and splitting it again by commas later. If you join "Leawood" and "KS" into "Leawood, KS" and then read the last segment as the country, you'll treat a US state code as a country code.
JSON-LD, when present
Some tenants include a schema.org JobPosting block. On some it's sparse (just title and datePosted); on others it's complete, with jobLocation.address, hiringOrganization and validThrough. When jobLocation.address is present, prefer it to the HTML labels, because it already separates city, region and country. hiringOrganization.sameAs is the employer's own website on the tenants we saw.
Don't use the JSON-LD description, though. Where we saw it, it was double-HTML-escaped (&amp;, &nbsp;) with no paragraph or list markup, so the HTML description is the better copy. See the JobPosting schema guide for the other fields.
Posting Dates
Use the first of these that exists:
- JSON-LD
datePosted, an ISO date. - A "Date published" label (also seen as "Date Posted", "Posted Date" and "Posting Date"). It's usually
DD-Mon-YYYY, such as07-Nov-2023, and the month abbreviation follows the site's language, so a Portuguese tenant writes "Out" for October. Parse the parts yourself rather than handing the string to a date parser, which will also apply your server's time zone to a date that has none. One tenant used07/16/2026under "Posting Date" instead. sitemap.xml. Many tenants publishhttps://{host}/{basePath}/sitemap.xmlwith alastmodper job URL. On the tenants we checked it was a real per-job value, spread over months or years, not one site-wide stamp. It's "last modified", though, so an edited old job can look newer than it is. Some tenants return an empty sitemap or list only static pages.
Fetching Jobs (Node/TypeScript)
import * as cheerio from "cheerio";
const HEADERS = {
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
accept: "text/html",
"accept-language": "en-US,en;q=0.9",
};
async function getHtml(url: string) {
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw Object.assign(new Error(`Avature ${res.status} for ${url}`), { status: res.status });
return res.text();
}
export async function listJobLinks(host: string, basePath: string) {
const jobs = new Map<string, { id: string; title: string; url: string }>();
for (let offset = 0; offset < 50_000; offset += 10) {
const html = await getHtml(`https://${host}/${basePath}/SearchJobs${offset ? `?jobOffset=${offset}` : ""}`);
const $ = cheerio.load(html);
let added = 0;
$('a[href*="/JobDetail/"]').each((_, el) => {
const href = $(el).attr("href") ?? "";
const id = href.match(/\/JobDetail\/[^/]+\/(\d+)/)?.[1];
const title = $(el).text().trim();
if (!id || jobs.has(id) || !title) return;
jobs.set(id, { id, title, url: new URL(href, `https://${host}`).toString() });
added++;
});
if (added === 0) break; // no new IDs: past the last page
}
return [...jobs.values()];
}
function readFields($: cheerio.CheerioAPI) {
const fields: Record<string, string> = {};
let description = "";
let longest = 49; // shorter than this is never a real description
// Template 1: <strong>Label:</strong> value
$("strong").each((_, el) => {
const label = $(el).text().trim().replace(/:$/, "");
const text = $(el).parent().text().trim();
if (label && text.startsWith(label)) {
const value = text.slice(label.length).replace(/^:\s*/, "").trim();
if (value) fields[label] = value;
}
});
// Templates 2 and 3: label/value blocks. The longest value is the description.
const shapes = [
[".article__content__view__field", ".article__content__view__field__label", ".article__content__view__field__value"],
[".fieldSet", ".fieldSetLabel", ".fieldSetValue"],
];
for (const [block, labelSel, valueSel] of shapes) {
$(block).each((_, el) => {
const label = $(el).find(labelSel).first().text().trim().replace(/:$/, "");
const $value = $(el).find(valueSel).first();
const value = $value.text().trim();
if (label && value) fields[label] = value;
if (value.length > longest) {
longest = value.length;
description = $value.html() ?? "";
}
});
}
return { fields, description };
}
function readJsonLd(html: string) {
for (const m of html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)) {
try {
const data = JSON.parse(m[1]);
if (data?.["@type"] === "JobPosting") return data;
} catch {
// malformed block: try the next one
}
}
return null;
}
export async function readJob(job: { id: string; title: string; url: string }) {
const html = await getHtml(job.url);
const $ = cheerio.load(html);
const { fields, description } = readFields($);
const ld = readJsonLd(html);
const byLabel = (re: RegExp) => Object.entries(fields).find(([label]) => re.test(label))?.[1] ?? null;
return {
id: job.id,
title: $('[itemprop="title"]').first().text().trim() || ld?.title || job.title,
description:
$('[itemprop="description"]').first().html() ||
$(".article__content--rich-text").first().html() ||
description,
address: ld?.jobLocation?.address ?? null, // structured, when present
locationText: byLabel(/location/i),
city: byLabel(/city|town/i),
state: byLabel(/state|province/i),
country: byLabel(/country/i),
datePosted: ld?.datePosted ?? byLabel(/^(date published|date posted|posted date|posting date)$/i),
workTime: fields["Working Time"] ?? fields["Employment Type"] ?? null,
website: ld?.hiringOrganization?.sameAs ?? null,
url: job.url,
};
}datePosted from the label is still a raw string at this point; parse it as described above, and fall back to the sitemap lastmod for the same job ID when neither source exists.
Gotchas
Bursts get you blocked, across tenants. Avature answers too many requests with 406 or 429. In our tests a burst of a few hundred detail requests on one tenant's subdomain got requests to a different tenant's subdomain rejected as well, so the limit doesn't seem to be per tenant. The block cleared on its own within a few minutes. Keep concurrency low, back off on 406 and 429, and don't run several Avature tenants at full speed in parallel.
Every job needs its own request. The list only gives IDs and titles, so a 300-job board is about 30 list requests plus 300 detail requests. On repeat runs, skip IDs you've already stored.
Expired jobs redirect to an error page. A job that's been pulled may still appear in a list you fetched a minute ago, but its detail URL redirects to the tenant's /Error page, which is served as a 404. Treat that as closed, not as a transient failure.
No company name in the markup. Apart from JSON-LD's hiringOrganization on some tenants, the pages don't name the employer or link to its website. You'll need to carry the company name yourself.
No webhooks or published limits. It's pull-only. Poll the list and compare IDs between runs to detect closures, and only expire jobs after a complete list fetch. See syncing job data in depth.
When This Is Enough
For one employer with a few hundred jobs, the approach above works and costs nothing. The fragile parts are the per-tenant templates and the request volume.
When It Isn't
Across many Avature employers you'll keep meeting new templates, new labels and new languages, and that's before the other platforms on the ATS public job postings API list. iCIMS and Taleo are the closest comparisons: HTML lists plus a request per job.
CleanJobData reads Avature career sites along with other platforms and returns one schema, with locations resolved to cities, states and countries, a remote type, and company details. Try it in the Playground or see the API Documentation.
Skip the scraper: get this data from the CleanJobData API
If you'd rather not maintain an Avature collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in pagination.next_page.
curl "https://api.cleanjobdata.com/jobs?title=logistics&location=DE&limit=20" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"Jobs from ATS-hosted career sites come back in the same schema as every other source, so there's no per-template parsing on your side. Use company_website_url or employer_id to scope to one employer. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.
- Jobs API reference for every filter and response field
- Playground to run queries without writing code
- Pricing — the Trial is free with no card required
FAQ
Does Avature have a public jobs API?
No. Avature's integration APIs need credentials from the employer's account. For other companies' published jobs, you page through the career site's SearchJobs HTML and read each JobDetail page.
How does Avature pagination work?
Add jobOffset to the SearchJobs URL and increase it by 10 for each page. Stop when a page returns no job IDs you haven't already seen, rather than relying on a fixed page size.
Why do Avature job pages look different from one company to the next?
Each employer customizes its career site templates, so field labels, CSS classes and even the language change by tenant. Match job links by their /JobDetail/ URL pattern, and treat the longest field on a job page as the description.
Why am I getting 406 errors from Avature?
Avature uses 406 as well as 429 to throttle bursts of requests, and the block can extend to other tenants' subdomains. Slow down, back off, and retry after a few minutes.