Docs / Job Data API / Endpoints / Company jobs
Company jobs
The same shape and filters as /v1/jobs, scoped to one company. Accepts either the company slug or its display name, so a caller holding only the name from a previous response need not look the slug up first.
Parameters
| Parameter | Type | Description |
|---|---|---|
id required |
string | Company slug or name. |
q |
string | Full-text match within this company's postings. |
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/companies/fincaazerbaijan/jobs \
-H "Authorization: Bearer $SOFTON_KEY" \
-d limit=50 -d order=-posted_at
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.softon.dev/v1/companies/fincaazerbaijan/jobs", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SOFTON_KEY")) q := req.URL.Query() q.Set("limit", "50") q.Set("order", "-posted_at") 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/companies/fincaazerbaijan/jobs" + "?" + urllib.parse.urlencode({ "limit": "50", "order": "-posted_at", }) 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/companies/fincaazerbaijan/jobs"); url.searchParams.set("limit", "50"); url.searchParams.set("order", "-posted_at"); 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-filiallari-ucun-filial-kredit-satis-iscisi-33160475",
"source": "glorri",
"title": "Neftçala filialları üçün Filial Kredit Satış İşçisi",
"company": { "name": "FINCA Azerbaijan", "slug": "fincaazerbaijan" },
"location": "Salyan, Azərbaycan",
"remote": false,
"employment_type": "Full-time",
"job_function": "Bank services",
"career_level": "Professional",
"posted_at": "2026-08-04T13:49:00.287Z",
"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.