Docs / Job Data API / Endpoints / List jobs
List jobs
Returns postings newest first -- -posted_at, with undated postings last. Every filter is optional and they combine with AND. An unrecognised parameter is rejected with a 400 rather than silently ignored, so a typo in a filter name cannot look like an empty result set.
facts.birjob_source_name is removed on 2027-01-30. Instead, read source_name or source, which name the same source on every posting. Until then, a response that carries it says so in the Deprecation, Sunset and Link headers. Announced on 2026-10-31.
Query parameters
| Parameter | Type | Description |
|---|---|---|
q |
string | Full-text match on title, company and description. |
remote |
boolean | Restrict to remote-eligible postings. |
company |
string | Company slug or name. |
updated_after |
timestamp | Only rows whose ingested_at is later than this instant — everything that CHANGED since your last poll, new and re-seen alike. Pass the newest ingested_at you have stored. Also returns a posting whose only change was going inactive: retirement deliberately does not move ingested_at, so this matches deactivated_at as well and a mirror is told when a posting dies. |
first_seen_after |
timestamp | Only postings this platform saw for the FIRST time after this instant. A refresh re-observes thousands of rows; across a day about 1,400 are genuinely new, which is roughly 15 requests — measured by walking a 24-hour window to exhaustion, not estimated. first_seen_at never moves once set, so it is safe as a watermark. |
posted_after |
date | ISO-8601 date. Filters on posted_at -- the date the SOURCE published the posting, not the date we first saw it. Page by order=ingested_at for the latter. |
location |
string | Substring match on location, case-insensitive. That field is free text at whatever precision the source publishes, so this matches a city inside "Bakı, Azərbaycan" -- and equally matches a country on the sources that publish only one. Not a place lookup: nothing is geocoded, and a spelling the source does not use will not match. |
employment_type |
string | Exact match on employment_type, case-insensitive, and treating - and _ as the same character. Two vocabularies are in this column: sources that derive the value from body text emit a schema.org token, and the rest pass through the employer's own label. The fold joins the pairs that differ only in punctuation — FULL_TIME/Full-time, PART_TIME/Part-time, TEMPORARY/Temporary — and it does NOT join CONTRACTOR/Contract or INTERN/Internship, which are different words. Query those separately; nothing here maps one vocabulary onto the other. |
job_function |
string | Substring match on job_function, case-insensitive. Each source's own taxonomy, not normalised between them, so treat this as a search rather than an enum. |
career_level |
string | Substring match on career_level, case-insensitive. The source's own label, not normalised. |
has_salary |
boolean | Only postings that publish pay (true), or only those that do not (false). Most do not: a minority of sources quote a figure at all, so this is usually the first filter to reach for if you are working with pay. Either end counts — a posting advertising a floor with no ceiling publishes pay. |
salary_from_at_least |
number | Minimum published pay, compared against salary_from — the FLOOR a posting quotes, not the ceiling. A posting offering 800–2000 does not pay at least 1500, and matching it on salary_to would answer a different question. Postings that publish no floor are excluded rather than assumed: null means the source did not say. Nothing is converted between currencies — pair this with salary_currency. |
salary_currency |
string | Exact match on the source's own currency code, case-insensitive, usually AZN. Pay figures are not converted, so a bare number means different things on different postings; this is how you pin one. |
count |
boolean | Adds meta.total — how many rows the filter matches in ALL, across every page. meta.count is and remains the size of THIS page.
Opt-in because it is a second query over the whole match, and a caller paging a feed should not pay for a number they never read. Exact rather than estimated: a filtered total is something you put on a page ("1,247 jobs in Sumqayıt"), and an estimate that is wrong by an unbounded amount is worse than no number.
If the count fails the page still returns, without total — the rows are the answer. |
include |
string | Opt a collection into fields that are off by default. One value today: bodies, which adds description_html and requirements_html to every row.
They are off by default because a body roughly doubles the size of a row that has one, and most callers walking 100 at a time do not want that. Ask for them when you do — hydrating a corpus one /v1/jobs/{id} call at a time would cost more requests than any plan allows, which made a documented feature unreachable.
Comma-separated for future values. An unrecognised token is a 400 rather than being ignored, so a typo cannot look like it worked. |
city |
string | Exact match on the normalised city, case-insensitive so the diacritics do not have to be typed. Not a substring search, deliberately: matching a city name loosely is what makes an employer called "Bakı Bank" in Sumqayıt show up under Bakı. Only postings whose source published a place are returned; ones whose location was recovered from the posting body are not, because that city may be the head office. |
district |
string | Exact match on the Baku rayon, case-insensitive. Narrower than city and only ever set for Baku postings whose source named a rayon. |
country |
string | Exact match on the ISO 3166-1 alpha-2 country, case-insensitive — ?country=AZ. This is the filter for "only postings I can actually serve": before it existed, an Azerbaijan-market board had no way to ask this API for Azerbaijan, and a source publishing its global board put Houston roles on two Azerbaijani job boards.
ISO 3166-1 alpha-2 — the two-letter code, not a name and not a language tag. The United Kingdom is GB, never UK; EN is a language and not a country at all. One code, not a list: ?country=AZ,GE and a repeated ?country= do not select two countries. Any value that is not two letters is a 400 naming what you sent — except an empty ?country=, which is the same as not sending it and applies no filter. UK gets a 400 telling you the code is GB — an empty page for a mistyped code reads as "you hold nothing there", which is the one thing this filter must never say by accident. A code the standard DOES define but this corpus has no rows for returns an empty page, because that answer is true.
It returns only postings whose source PUBLISHED a country, which is a minority of the corpus — most rows are null because the board never said. So ?country=AZ is much narrower than "jobs in Azerbaijan", and there is no way to ask for the nulls. If you want everything except the foreign rows, filter them out on your side by the country you get back rather than asking for one here. |
deadline_after |
date | ISO-8601 date. Only postings whose application deadline is on or after it — pass today's date for "still open". Postings with no deadline are excluded, and most have none, so this narrows hard. Unlike posted_at a future date is kept rather than nulled: that is what a deadline is. |
active |
string | Which postings to return. any — the default today — is every posting this platform holds; true is only those whose source was still publishing them at its last complete scrape; false is only the retired ones. The default is any rather than true on purpose — a retired posting is accurate data with an archival use, unlike the inactive half of the quotes pool — but if you are showing postings to jobseekers, pass true.
The default stays any. Moving it to true was announced on 2026-10-03 and withdrawn on 2026-10-15, before it took effect, so that no integration's results change under it.
Most of the corpus is retired: 39,046 postings were still open of 149,383 held when this was measured (2026-08-28). So the default walk returns roughly four rows of history for every live vacancy, and coverage of the optional fields is markedly worse on the retired ones. If you are sizing the dataset rather than archiving it, pass true and read live_records on GET /v1/sources. |
source_type |
string | Restrict to whole CLASSES of publisher, rather than to named sources. Comma-separated; the one filter here that takes a list, because the question it answers is a union.
employer — one organisation's own careers page, government bodies and universities included. platform — a multi-tenant careers platform, where each posting is still an employer's own (Glorri). agency — a recruitment or staffing firm posting for clients. board — a multi-employer job board. aggregator — republishes other boards' postings. unknown — four archive stems with no module and no registry entry, classified honestly rather than guessed.
So everything that did not come from a job board is ?source_type=employer,platform,agency — one filter instead of fifty ?source= calls. Measured 2026-08-28, that is 2,150 of the 39,046 live postings; the boards are 31,775 of them. Size it before you build on it.
This is our classification, not a fact the source asserts. It is the one editorial field in this API: a value can change when a source is re-read, and the calls most likely to be argued with are recorded in the repository. The SET of values is stable. GET /v1/sources carries each source's source_type so you can see what a filter will include. |
limit |
integer | Items per page, 1–100. Defaults to 25. |
cursor |
string | Opaque cursor from a previous response's meta.next. Do not construct one. |
order |
string | Sort field: ingested_at, posted_at, each optionally prefixed with - to reverse. Default is -posted_at. Rows with no value for the sort column always sort LAST in either direction, so paging never leads with undated rows. |
source |
string | Restrict to ONE scraper source, by its stem — abb, not ABB or abb-bank.az. Not a list: ?source=a,b and a repeated ?source= do not select two sources. On /v1/jobs an unknown stem is a 400 naming it, and source_type is the way to select several sources at once. GET /v1/sources lists every stem. |
Request
curl -G https://api.softon.dev/v1/jobs \
-H "Authorization: Bearer $SOFTON_KEY" \
-d q=FINCA -d remote=false -d updated_after=2026-08-16T09:00:00Z
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.softon.dev/v1/jobs", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SOFTON_KEY")) q := req.URL.Query() q.Set("q", "FINCA") q.Set("remote", "false") q.Set("updated_after", "2026-08-16T09:00:00Z") req.URL.RawQuery = q.Encode() res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() var page struct { Data []Job `json:"data"` Meta struct{ Next *string `json:"next"` } `json:"meta"` } if err := json.NewDecoder(res.Body).Decode(&page); err != nil { log.Fatal(err) } // page.Meta.Next → send it back as ?cursor= for the following page
import json, os, urllib.parse, urllib.request url = "https://api.softon.dev/v1/jobs" + "?" + urllib.parse.urlencode({ "q": "FINCA", "remote": "false", "updated_after": "2026-08-16T09:00:00Z", }) req = urllib.request.Request(url, headers={ "Authorization": "Bearer " + os.environ["SOFTON_KEY"], }) page = json.load(urllib.request.urlopen(req)) # page["meta"]["next"] → send it back as ?cursor= for the following page
const url = new URL("https://api.softon.dev/v1/jobs"); url.searchParams.set("q", "FINCA"); url.searchParams.set("remote", "false"); url.searchParams.set("updated_after", "2026-08-16T09:00:00Z"); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SOFTON_KEY}` }, }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { data, meta } = await res.json(); // meta.next → send it back as ?cursor= for the following page
Response
The envelope is identical on every softon.dev API: data, meta, error. Only the shape inside data changes per dataset — see the response envelope.
{
"data": [
{
"id": "glorri:fincaazerbaijan-neftcala-filiali-uzre-filial-kredit-satis-iscisi-72665882",
"source": "glorri",
"title": "Neftçala filialı üzrə Filial Kredit Satış İşçisi",
"company": { "name": "FINCA Azerbaijan", "slug": "fincaazerbaijan" },
"location": "Neftçala, Azərbaycan",
"remote": false,
"employment_type": "Full-time",
"job_function": "Bank services",
"career_level": "Professional",
"posted_at": "2026-10-05T09:39:25.578Z",
"url": "https://jobs.glorri.az/vacancies/fincaazerbaijan/fincaazerbaijan-neftcala-filiali-uzre-filial-kredit-satis-iscisi-72665882?isLocal=true",
"description": "FINCA Azerbaijan” Qeyri-Bank Kredit Təşkilatı Neftçala filialı üçün Filial Kredit Satış İşçisi vəzifəsinə vakansiya elan edir.…",
"requirements": "- Təhsil: Ali və ya Kollec təhsili;…",
"ingested_at": "2026-10-09T15:02:17.774673Z"
}
],
"meta": { "count": 1, "next": "eyJvIjoyfQ", "request_id": "req_9Fv3" },
"error": null
}
Fields of each item in data[]
| 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. |
Explore a documented example
This preview shows the documented response. It does not call the live API or use your key.
Choose Show example to view the documented response.