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.
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`. **This default is going to move to `true`**, and a default is part of the contract, so it is announced ahead of time under Queued on `/docs/shared/versioning` rather than shipped quietly. Passing `active` explicitly is immune to the change either way: `any` keeps today's behaviour permanently, `true` gets tomorrow's now. **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=golang -d remote=true -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", "golang") q.Set("remote", "true") 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": "golang", "remote": "true", "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", "golang"); url.searchParams.set("remote", "true"); 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-08-04T13:49:26.33Z",
"url": "https://jobs.glorri.com/en/vacancies/fincaazerbaijan/fincaazerbaijan-neftcala-filiali-uzre-filial-kredit-satis-iscisi-72665882",
"description": null,
"requirements": null,
"ingested_at": "2026-08-04T23:55:43.722474Z"
}
],
"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}. |
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 122 of 130 sources. Null on eight rather than guessed: three are hosted entirely on a third party's applicant-tracking system and their own domain appears nowhere in our scrapers (their `jobs_url` on `/v1/sources` still points at the ATS page the postings are actually on), one has only its ATS vendor's domain recorded against it, `other` is a catch-all bucket with no website, and **three we have measured as gone** — a hosting placeholder on every path, an organisation that no longer exists and now redirects to a different agency, and a decommissioned ATS tenant. Those three 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. `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 we can only give when the source has run.** `false` is a real 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. `true` is **weaker than it looks, and you should not read it as "verified live today"**. It is the default state, and the only thing that ever changes it is the retirement pass — which fires only when the source itself completes a run. A source that has stopped being scraped produces no verdict at all, so every one of its postings stays `true` indefinitely, and this description used to illustrate that with a number that was simply wrong — `jobsite_az` holding "over 12,000 postings, all still `true`". It holds 12,667 rows of which **1,199 are active**; the other 11,468 were retired on 2026-08-12, before that sentence was ever written. The figure was a corpus total relabelled as an active count. **You no longer have to take our word for the size of it.** `GET /v1/sources` publishes `live_records` beside `records` per source, so the gap between the two is exactly the population whose `true` carries no verdict — and a source whose `last_ingested_at` is old is one where that gap is unmeasured rather than small. Two sources report thousands of `records` and zero `live_records`. **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_ingested_at` from `GET /v1/sources`: the source ran recently AND carried this row means live; the source ran recently and did NOT means gone; the source has not run means nobody knows, including us. 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. |
deactivated_at |
timestamp · nullable | When this platform concluded the posting had gone, RFC 3339, and null while it is active. Not when the employer took it down — nothing here can observe that — but at most one grace window later. `ingested_at` deliberately does not move when a posting is retired, so this is the timestamp that changed; `updated_after` matches on either. |
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 | Absolute URL of the company's logo on the source's own site. Not mirrored and not revalidated, so it can 404 independently of the posting — treat a broken image as expected rather than as an error. |
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 nine of the ~120 scrapers; 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. |
Try it
Send the request to see a response.