Docs / Job Data API
Job Data API
Postings from company career pages and boards, deduplicated per source, with the source's own employment type, job function and career level preserved rather than mapped to a taxonomy that would hide disagreements.
Live
https://api.softon.dev/v1
v1 · stable
Endpoints
| Endpoint | Route | Returns |
|---|---|---|
| List jobs | GET /v1/jobs |
collection |
| Retrieve a job | GET /v1/jobs/{id} |
single object |
| Batch liveness | POST /v1/jobs/liveness |
collection |
| Company jobs | GET /v1/companies/{id}/jobs |
collection |
The Job object
The fields below appear on the dataset's primary resource. Endpoint pages list any fields specific to them.
| Field | Type | Description |
|---|---|---|
id |
string | Stable identifier, "<source>:<source id>". Pass it to /v1/jobs/{id}.
The source id is the posting number the site itself publishes, for every source but eight. Those eight publish none: ddla_gov_az, ishelanlari_az, kaspi_edu_az, konsis, mdm, regulator, royalesb and ziraat, and for them it is the first sixteen hex digits of the SHA-1 of the posting's URL, or of its title for mdm, whose postings all share one URL. So an id changes only when the site renumbers a posting, or, for those eight, changes its URL (mdm: its title, and the old row stays until a run retires it for being absent). A record from any other source that arrives without its id is dropped, never given a new one. |
source |
string | Which scraper produced this posting. A stable slug, and half of id — this is what ?source= takes, so it never changes even when a board renames itself. GET /v1/sources lists every slug with its display name, its dataset, how many rows it currently has and when it last landed a batch. That endpoint needs no key. |
source_name |
string · nullable | The same source as a person would write it — ABB for abb, 1is.az for 1is_az. This is the one to display. Null for the handful of sources the scraper registry has no entry for, rather than repeating the slug. |
source_url |
string · nullable | The source's own website — https://glorri.com for glorri, https://abb-bank.az for abb. This is the one to link source_name to.
Not this posting's link — that is url — and not the source's listing page either. GET /v1/sources carries both, as url and jobs_url, and needs no key; jobs_url there is the page this platform reads the source's vacancies from, which is the better link if you are sending somebody to browse a source rather than to read about it.
Set for nearly every source; GET /v1/sources says which, live. Null rather than guessed where there is no honest answer: sources hosted entirely on a third party's applicant-tracking system, whose own domain appears nowhere in our scrapers (their jobs_url on /v1/sources still points at the ATS page the postings are actually on), a source with only its ATS vendor's domain recorded against it, other, a catch-all bucket with no website, and sources we have measured as gone — a hosting placeholder on every path, an organisation that no longer exists and now redirects to a different agency, a decommissioned ATS tenant. Those keep their rows and lose their link, because a link to nothing is worse than none. |
title |
string | Posting title as published. |
company |
object · nullable | The hiring company. company.name is always present when the object is; company.slug is nullable and is null on most rows -- do not type it as required. The whole object is null when the source omits the company, and is ALWAYS null on classifieds sources: there the advert is placed by a private individual rather than an employer, so there is no hiring company to name and publishing the poster under this field would be publishing a person's name as a company (#83). |
location |
string · nullable | Free text at whatever precision the source itself publishes — a city, a city and country, a district, an office or branch name, or the country alone. Not normalised, not geocoded, and not comparable between sources. Some boards publish no city at all and this is their country; nothing is inferred from the posting body to fill the gap, because a city read out of prose is as likely to be the employer's head office as the job's location. |
city |
string · nullable | 25% of live postings — 9,690 of 39,046, counted exactly on 2026-08-28 by summing ?city= across the whole vocabulary. This said 60% and climbing, which was a projection rather than a measurement and is not where it is heading: the field fills only from sources that publish a structured place, about twenty of a hundred and thirty do, and several of the largest sources never will. Nine in ten of the values are Bakı. The same place as location, normalised to a closed vocabulary of Azerbaijani cities so it can be filtered and grouped — which location cannot be. Null unless the source published a place of its own: a city inferred from advert prose is as likely to be the employer's head office as the job's, so postings whose location was recovered that way carry a null city and keep their location. Azerbaijani cities only — a posting in Moscow or Warsaw is null here and unchanged in location. location is untouched and stays the higher-precision value; this is the comparable one. |
district |
string · nullable | The Baku rayon, where the source names one — Nəsimi, Binəqədi, Xətai and the other nine. Null everywhere else, including for Baku postings that name no rayon. Implies city: a rayon is Baku's, so city is always Bakı when this is set. Present because one source publishes a rayon where every other publishes a city, and because a per-rayon page needs rayon granularity that a city vocabulary cannot express. |
country |
string · nullable | The posting's country as ISO 3166-1 alpha-2 — AZ, PL, US. Null unless the source itself published a country, which most do not: an Azerbaijani board's postings are Azerbaijani by construction and it never says so on the row. So null means nobody said, NOT somewhere else — use this to narrow what you serve, never to conclude a posting is foreign.
Independent of city, not a widening of it. city is an Azerbaijani-only vocabulary, so a Warsaw posting is null there and PL here, while a Baku posting whose source named no country is Bakı there and null here.
It comes from a structured country field where the source publishes one (Oracle ATS PrimaryLocationCountry, jobpilot's country_code) and from a country component of location where the source appends one — the same provenance rule as city: nothing is inferred from the posting body, and a token that is not an ISO code is stored as null rather than published. |
remote |
boolean · nullable | Whether the posting is remote-eligible. |
employment_type |
string · nullable | Full-time, Part-time, Contract, as the source labels it. |
job_function |
string · nullable | The source's own function taxonomy, not normalised across sources. |
career_level |
string · nullable | The source's own seniority label. Not normalised, so it is not called "seniority". |
salary_from |
number · nullable | Bottom of the published pay range, in salary_currency. Null where the source does not publish pay, which is most postings -- and null rather than 0, which would be a figure. Nothing is inferred from the posting body. |
salary_to |
number · nullable | Top of the range. Either end can be null on its own: plenty of postings advertise a floor with no ceiling. |
salary_currency |
string · nullable | The source's own currency code for the range, usually AZN. Not converted, so two postings in different currencies are not comparable by number alone. |
salary_period |
string · nullable | What the figures are per: HOUR, DAY, WEEK, MONTH or YEAR. Null unless the source itself stated it, which today is about 1% of live postings and 6% of those that carry pay at all, and always MONTH — the eight sources that publish a period all publish that one. Null is the useful answer, not a gap: it means nobody knows, so do not assume. If you emit JobPosting unitText, omit it when this is null rather than defaulting to MONTH; a figure published under the wrong period is wrong by 12x. Nothing here is inferred from the amount, and nothing is read out of the posting body — a period word near a salary is usually about a shift, a rest day or required experience, not about pay. |
deadline_at |
timestamp · nullable | Application deadline as published, RFC 3339. Unlike posted_at a future date is kept rather than nulled -- a date the clock has not reached is what a deadline is. Not a liveness signal: a posting is not withdrawn here when its deadline passes, and most postings have none. |
posted_at |
timestamp · nullable | When the posting first appeared, RFC 3339. |
url |
string · nullable | The posting's page on the source site — usually canonical, but see the last paragraph: on some sources it is a third-party redirect carrying search-session state rather than a canonical page. Not unique, in two different ways, and not stable over time in a third.
Same source. A board that lists one vacancy under two of its own ids yields two rows that differ by id and match by url, and they can land in the SAME page of a collection — so a consumer keying a table on url must deduplicate before writing, or one duplicated pair will reject the whole statement and take the other 98 rows of the page with it. Collapse these by keeping the newer ingested_at.
Different sources. An aggregator republishes another board's posting under its own id, carrying the ORIGINAL board's url. You then hold two records for one job with the same url, different source, and different id. This pair cannot be collapsed the same way — the two disagree about which <source>:<source_job_id> addresses the posting, so whichever you keep decides the id you can look it up by afterwards. Pick the record whose source owns the url's domain, or skip aggregators entirely: GET /v1/sources marks them with "aggregator": true.
Over time. The same id can return a DIFFERENT url on any scrape, so a mirror cannot key on it — this is the arity the other two paragraphs do not cover, and unlike them it cannot be absorbed by deduplicating a page. Measured on jobpilot_remote: one id emitted 39 distinct urls over five weeks for one unchanged posting. Both the path and the query rotate (/away/ vs /desc/), and jobAge increments by exactly 24 every scrape, so the url is guaranteed to differ on every daily run. It is source-specific rather than fleet-wide: 11,722 of 11,722 sampled active urls on that source carry tracking parameters, against 0 of 100 on each of boss.az, tap.az, jobsearch.az and Glorri. Those redirect urls are also not canonical pages — ckey= is the search query somebody typed, not part of the posting's address.
So: id is the identifier, and this is a link that moves. Key on id, store url as an attribute, and if you must group by url, strip the query string first. |
description |
string · nullable | Full posting body. Present when the crawler has fetched the posting's own page, null otherwise — on collections and on /v1/jobs/{id} alike, since neither fetches on demand.
92% of live postings, counted over a complete walk of them on 2026-08-28, and 97% on source_type=employer,platform,agency. The gap is per-source and mostly upstream: a handful of sites give a listing and no reachable detail page. It is markedly worse on RETIRED postings, so a walk left at today's default ?active=any will look thinner than the live data actually is. |
requirements |
string · nullable | Requirements section when the source separates it, subject to the same condition as description. Present on about 11% of live postings (this said 5%, measured against a different denominator) — most boards write one prose block and it lands in description. Concentrated rather than spread: Glorri separates it on virtually every posting and boss.az on most, and the rest of the fleet barely at all. Do not build a layout that needs this. |
facts |
object · nullable | The upstream columns this platform does not publish as their own field, under one key because only a handful of sources fill them. Five of the keys the live fleet fills — work_type, company_logo, view_count, contact_email and contact_phone — are typed fields of their own now and are still echoed here as strings; the typed fields are the ones to read, and view_count in particular can appear here as an abbreviated string like 1.3K that will not cast. company_logo, where it is here at all, is the typed field's value, cleaned the same way, and it is never here when that is null. slug is the source's own. The two contact keys are rare — see contact_email for where each one comes from and for the two conditions an address named in a posting body has to meet before it is published. Note that a minority of rows imported from the upstream board's database carry an address THAT board extracted from advert text before the import, under rules this platform does not apply to its own crawling. They are the employer's own application contact for that posting, given for applications: do not use them to build a contact list, and do not send anything to them that is not a reply to the posting they came with. Postings imported from the upstream job board's own database — a large part of the archive — carry more, including that board's internal bookkeeping (is_active, flag_status, created_at and click_count). Do not read those back. They are the origin board's own columns about its own copy, frozen at the moment we imported its database — a mirror reading facts.is_active would resurrect its own retired rows from its own stale data. The active field is this platform's answer and the only one that moves. Treat the key set as per-row and per-source rather than as a schema: check for a key, do not assume it. Every value is rendered as a string whatever its underlying type, and a nested object or array is dropped rather than serialised. Unlike every other nullable field here the key is ABSENT rather than null on a posting with none of them. Salary and the deadline used to live here and are now typed fields of their own; they are still echoed inside facts as strings, and the typed columns are the ones to read. |
ingested_at |
timestamp | When this version was stored. Moves every time a scrape re-sees the posting. |
first_seen_at |
timestamp | When this platform first saw the posting. Never changes — this is what distinguishes a new posting from one you already hold, since ingested_at moves on every re-sighting. |
times_seen |
integer | How many scrapes have seen this posting. A source whose rows are all at 1 is changing its own identifiers rather than posting new work. |
active |
boolean | A verdict, and a real one needs the source to have run. false is usually a finding: the source delivered a complete scrape, this posting was not in it, and nothing had seen it for 24 hours — roughly three consecutive misses on the published schedule. It is also written when a source has stored nothing at all for 14 days, by a daily pass that retires every posting that source still holds — and that false is not a finding. The posting may well still be open; what it says is that we stopped vouching for it. inactive_reason says which kind of false you have.
true is weaker than it looks, and you should not read it as "verified live today". It is the default state: it means nothing has retired the posting, not that anything confirmed it. A source that stops delivering gives no verdict on its postings, so they stay true until the daily pass retires them, at most about 15 days after the source's last delivery. The pass can also decline, and then they stay true until someone has looked: it changes nothing while fewer than half of the sources that delivered in the last 44 days have delivered in the last 36 hours — an outage on our side, not theirs — or when what it would retire is more than a tenth of every live posting. Either way, a posting is true again on the next run that carries it.
You do not have to take our word for the size of it. GET /v1/sources publishes stale_live_records per source: how many of its live_records no run has carried for seven days, which is the population whose true carries no verdict, counted. On a source whose last_observed_at is old it is all of them.
If liveness matters to you, derive it yourself — you have everything you need and it is four lines. Compare this posting's ingested_at against its source's last_observed_at from GET /v1/sources: the source delivered recently AND carried this row means live; the source delivered recently and did NOT means gone; the source has not delivered means nobody knows, including us, whatever active says. Use last_observed_at and not last_ingested_at, which moves whenever a batch lands even if nothing in it could be stored. See the worked example in the freshness docs. Nothing stops you being stricter than we are, and for a jobseeker-facing product you probably should be.
It stays a boolean. Making it nullable was announced on 2026-10-03 and withdrawn on 2026-10-15, before it took effect: inactive_reason now says what a null would have said, beside an active that does not change. |
deactivated_at |
timestamp · nullable | When this platform retired the posting, RFC 3339, and null while it is active. Not when the employer took it down — nothing here can observe that. After a complete scrape that did not carry it, this is at most one grace window after the posting was last seen. For a posting retired because its source went silent, it is the day the daily pass ran, about two weeks after that source's last delivery, and no evidence the posting closed then or at all. ingested_at deliberately does not move when a posting is retired, so this is the timestamp that changed; updated_after matches on either. |
inactive_reason |
string · nullable | Why the posting is inactive, and null while active is true. One of seven values, fixed by a database constraint, so a new one is an announced addition, never a typo:
- absent_from_source: the source delivered a complete scrape without it, and nothing had seen it for a day. A finding about the posting.
- source_gone: the site that published it no longer exists. A finding.
- source_unreachable: the site still publishes, and our address can no longer fetch it. Not a finding: the posting may well still be open.
- source_origin_down: the site's edge answers and the server behind it does not. Not a finding, and usually temporary.
- source_dark: we have stored nothing from the source for 14 days. Not a finding: a fact about us, not about the posting.
- imported_inactive, imported_flagged: imported from an earlier archive that had already retired it, or held it as a suspected scam.
A posting seen again is active again, and this goes back to null. To keep only postings retired on evidence, filter on absent_from_source and source_gone. |
work_type |
string · nullable | The source's own working-arrangement label — on-site, hybrid, shift — as it writes it. Not normalised, and not the same axis as employment_type, which is about the contract. Null on most postings. |
company_logo |
string · nullable | The company's logo as an absolute http:// or https:// URL, or null — never a relative path, never a bare file name, and never HTML-escaped: &, <, >, ", ', ' and ' are decoded wherever a source left them, however many times over. It is the address the source serves the image from: not mirrored, not fetched by us and not revalidated, so it can 404 independently of the posting — treat a broken image as expected rather than as an error.
Where a source gives the logo as a path, it is resolved only where that source is known to serve its uploads from a fixed address, and a space in a file name is sent as %20. A path is never resolved against the posting's own page: the posting is not always the page the path was read from, and a guessed address that 404s is worse than none. Anything that cannot be made into a URL that way is null, and so is an image a source shows to mean the company has none — a nologo.png, a placeholder, the board's own brand mark. Null is common and it is a correct answer: render your fallback, and leave logo out of JobPosting markup. |
view_count |
integer · nullable | How many times the SOURCE says the posting has been viewed — its counter, not ours, and not comparable between sources. Approximate where the source abbreviated it: a board showing 1.3K has published somewhere in 1,250–1,349, and that precision is gone before it reaches us. Null where the source publishes no counter. |
contact_email |
string · nullable | About 6% of live postings, and the distribution matters more than the number. About 85% of these rows are on job boards, nearly all of that on four of them — jobsearch.az, boss.az, work.az and position.az — because a board's posting form asks the employer for a contact address. On source_type=employer it is about 28%, and on a board like boss.az it is every row. If you filter to direct employers and also need an address, those two requirements pull against each other: a company's own careers page routes applications through its own form or ATS, so most such postings publish no address at all and url is the apply path.
Where it is present it is the employer's own application mailbox for this posting, lower-cased, from one of two places and no others:
1. a structured field the source published — an API field, a mailto: or a tel: link — read by the few scrapers whose source publishes one;
2. an application mailbox named in the posting itself, under three conditions that all have to hold: the whole local part is a role name from a closed list (cv@, hr@, karyera@, vacancy@, tecrube@ …) with nothing appended, so hr.aliyeva@ does not qualify; the domain is the posting's own; and the posting comes from a source whose url identifies one organisation — a direct employer or a named recruitment agency, never a job board, a multi-tenant careers platform or a hosted ATS domain shared between employers. Together those mean a named individual's address cannot qualify and neither can a third party's. Ten distinct mailboxes account for every row filled this way.
Nothing else is read out of advert prose, deliberately. Use it to reply to the posting it arrived with; do not use it to build a contact list. |
contact_phone |
string · nullable | About 4% of live postings, and 98% of those are on jobsearch.az and boss.az alone — on employer career pages it is effectively absent (one row in the whole segment). Formatted exactly as the source published it, with no canonicalisation, because a number's formatting is theirs and normalising it here would be inventing one. Do not assume E.164.
Unlike contact_email this comes ONLY from a structured field — an API field or a tel: link. Nothing is read from the posting body, and the asymmetry is deliberate: a number has no local part, so there is no way to tell an employer's application line from someone's mobile the way a role mailbox can be told from a person's. Across a complete walk of the live corpus there were no tel: links in any posting body, so there is nothing being withheld. |