Docs / Shared / Versioning & changes
Versioning & changes
What "v1 · stable" commits to, what counts as a breaking change, how much notice you get, and everything that has changed so far.
What “v1 · stable” commits to
Three things, and they are the whole promise:
- A field that exists will not be removed or change meaning inside
v1. - A parameter that works will keep working, and keep meaning the same thing.
- An id stays valid.
<source>:<source_id>is stable for the life of a record — if a record is removed you get a410, never a silently reused id.
What it does not commit to: the data. Sources change what they publish, stop publishing, and get taken down. That is the product, not a version question.
What counts as breaking
| Breaking — you get notice | Not breaking — ships any time |
|---|---|
| Removing a field, or changing its type | Adding a field |
| Removing a parameter, or narrowing what it accepts | Adding a parameter |
| Changing what an existing parameter defaults to | Adding an error code for a case that used to be a generic failure |
| Changing an HTTP status for an existing case | Adding an endpoint |
| Making a nullable field non-nullable, or the reverse | Changing a message string (never branch on these — branch on code) |
The line that catches people: a default is part of the contract. Changing what ?active= returns when you do not pass it changes every existing integration's result set without changing one line of their code, so it is treated as breaking even though nothing was removed.
Notice
90 days for anything in the left column, announced here and by email to every account with a key that has been used in the last 90 days. A removed field keeps being served, unchanged, for the whole window.
Three exceptions, and they are the only three:
- A security fix — as fast as it takes, explained here afterwards.
- A legal removal. When an employer or an individual asks for a record to be removed we act immediately; that is what
410 goneis for, and it is not a version change. - A refusal for an input that could never have matched. Narrowing a parameter to reject a value no row could ever have carried, so the old answer was an empty collection and the new one is a
400. Nothing you could have received is withdrawn — what changes is the status, and the fact that you are now told. It is still a change to an HTTP status for an existing case, which is why it is listed here as an exception rather than moved to the right-hand column, and it is announced in the Changelog on the day it ships rather than 90 days before. The bar is narrow and it is the reason the exception exists: an empty page for a value we could never have held reads as “we hold nothing for you”, which is the one answer this API must never give by accident. Used twice so far — an unknown?source=stem on 2026-08-28, and a?country=that is not an ISO 3166-1 alpha-2 code on 2026-09-05. If either of those broke you, say so: it is the exception most likely to be wrong, because a client that treats every400as fatal is affected by it even though no data moved.
If a change would break you and 90 days is not enough, say so — it is easier to hear that before the change than after.
Queued
Three breaking changes are planned and none of them has a date yet. They are listed here because you should not learn about them from an email.
| Change | Effect | What to do now |
|---|---|---|
active becomes nullable, and null means “no verdict” |
Today true is the default state and doubles as “we have not heard from this source in months”. Splitting those apart is the only way the field can be trusted. Postings from a source that has not run recently will read null instead of true. |
Handle null now. Treat it as its own state rather than falsy — or better, derive liveness yourself and stop depending on this field at all. |
?active= on /v1/jobs moves from any to true |
Collections will exclude postings whose source has taken them down. Today they are included and carry "active": false. |
Pass active explicitly. active=any keeps today's behaviour forever; active=true gets tomorrow's now. |
The birjob_source_name key is removed from facts |
Affects only postings imported from the archive that predates this API. The key repeats, in an old project's vocabulary, what source and source_name already say as first-class fields on every row. |
Read source or source_name instead. They are present on every posting, not just imported ones, and they already carry the same value. Nothing else in facts is changing. |
A jobs API whose default includes withdrawn postings is answering the wrong question by default, which is why it is going to move. It has not moved yet because the retirement pass that decides active needs a quiet run of real traffic behind it first.
The first two are related and the order matters: defaulting the filter to true while true still means “no verdict” would make the default more misleading, not less — it would keep every posting from a dark source and drop only the ones we correctly retired. The nullable change comes first. The third is independent of both and can move on its own.
Neither is why active's documentation changed today. That was a correction, not a change: the field always behaved this way and the description overstated it. Nothing about the data moved.
Changelog
Everything that has changed on v1. Almost every entry is an addition. Two are not, and both say so where they appear: an unknown ?source= became a 400 on 2026-08-28, and ?country= was narrowed to refuse a code that is not one on 2026-09-05. Both fall under the third notice exception — a value neither could ever have matched — and neither withdrew a row anyone was receiving.
2026-09-15
- A fourth dataset:
/v1/rates, the Central Bank of Azerbaijan's official daily bulletin. 38 currencies and 4 bank metals against the manat — all 42 of them stored per document, with?codes=filtering on read. Three endpoints,GET /v1/rates,GET /v1/rates/latestandGET /v1/rates/{id}, on the same envelope, the same key, the same cursor paging and the samelimitcap of 100 as the other three. On the free tier. Reference. - Every row carries two dates, and that is the whole reason this dataset exists. The bank's XML is addressed by date and answers
200with a full, valid bulletin for every date string — Saturdays, public holidays, and dates that have not happened yet. Asking for Saturday 29.08.2026 returns Friday the 28th's numbers; asking for 01.01.2027 returns the newest bulletin there is. Nothing in the HTTP response says so. Sodateis the calendar date you asked about,effective_dateis the bulletin's ownValCurs Date=read out of the payload rather than inferred from the URL, andcarried_forwardis those two disagreeing. Passcarried_forward=falsefor a strict business-day series; take every row for a dense daily one and know which values are repeats. A mirror that keeps one date can express neither, and cannot tell a weekend from a fresh publication at all. rate_per_unitisvalue / nominal, unrounded — and it is almost certainly the number you want, notvalue. Seven currencies are published per 100: KRW, KZT, HUF, UZS, PKR, RUB and JPY. The rouble quotes as1.9579per hundred and is0.019579per rouble, and both figures are on the row, so nothing is rounded on the way to you and nothing has to be divided twice. The four bank metals are per troy ounce and say which they are:kindismetalandunitistroy_ounce.- Dates on this dataset are Asia/Baku. That is written down because it is a bug we inherited rather than a convention we chose: Azerbaijan is UTC+4 year-round with no daylight saving, so anything computing “today” in UTC is a day behind for the last four hours of every Baku day — and the pipeline this replaces did exactly that, silently, on every run started in the evening. Keyed on Baku here, and stated on every one of these three endpoints, so the next consumer does not re-derive it.
GET /v1/sourceslists thecbarsource, anddatasettakes a fourth value.recordsandlive_recordsare equal on it andqualityisnull, for the same reasons they are on events and quotes — the field table says which reason is which.- What this dataset does not carry, said here rather than discovered on the first sync. No
updated_afterand nofirst_seen_after; nofirst_seen_atand notimes_seen; no retirement pass, and noactive. A published bulletin is not withdrawn and a past date's numbers never move, so the watermark that means anything here isfrom— a filter on the bank's calendar, not on when we stored the row. Data freshness has the long version. - A fifth dataset:
/v1/genome/releases— which release each upstream genome data source is on, and when we last managed to read it. Four sources to start with: RefSeq, dbSNP, 1000 Genomes/IGSR and NCBI Datasets, probed once a day at04:40UTC and read from each source itself rather than hardcoded here. Two endpoints,GET /v1/genome/releasesandGET /v1/genome/releases/{id}, on the same envelope, the same key, the same cursor paging and the samelimitcap as the other four. On the free tier. Reference. - Release markers only — no sequence, no assembly, no annotation, no variant data, served here or held behind it. A row is one source's current release string, the date we last read it successfully, and how the last probe went. It is a provenance table rather than a genomics API, and it is published as one because four separate pipelines were each deriving that release string for themselves.
statussits besiderelease_version, and it carries the half a version number cannot. A release that has not moved in years and a probe that has been broken for two days are the same unchanged string otherwise — and both happen here: IGSR's newest data freeze really is20130502. Four values,ok,degraded(the host answered and the page no longer parses),unreachable(nothing answered at all) anderror(it answered, with a non-2xx), because a dead host and a changed template want different people. A failed probe keeps the last goodrelease_versionand movesstatusinstead, so compareretrieved_at, the last successful read, withingested_at, the last attempt, to see how old an answer is.- A sixth dataset:
/v1/reachability— whether a watched URL answered us, why not if it did not, and how long it has been that way. Six targets to start with, probed once a day at05:20UTC. No page content is read, stored or served — a status code is a fact about a request we made, which is why this is the one dataset here with nothing to license. Two endpoints,GET /v1/reachabilityandGET /v1/reachability/{id}, on the same envelope, the same key, the same cursor paging and the samelimitcap as the other five. On the free tier. Reference. reachable: falseis not a claim that a site is down, and the row says which it is.failure_classis a closed set of seven, andblocked_by_bot_protectionis separate fromhttp_erroron purpose: two of these six targets answer our client a bot-protection challenge page rather than the site, and we have measured that answer changing with nothing varying but the HTTP version negotiated — same client, same address, same minute. Every row also carriesvantage, the place the request left from, because the same URL has answered403to a datacenter address and200to a residential one on the same day.- Ids on this collection carry no
<source>:prefix, and it is the only place on this API where that is true.GET /v1/genome/releases/refseq, notncbi_ebi:refseq— which answers404. The row's identity is the upstream source, so the prefix would be a constant every caller had to strip to recoversource_id, which the same row already publishes. The database carries a unique constraint on that key, so the bare id resolves by rule rather than by coincidence. GET /v1/sourceslists thencbi_ebisource, anddatasettakes a fifth value.recordsandlive_recordsare equal on it andqualityisnull, for the same reasons they are on events, quotes and rates — nothing here is ever retired, and coverage percentages are questions about a vacancy.
2026-09-05
countryon every job row, and?country=on/v1/jobs. Both downstream consumers of this API are Azerbaijan-market job boards, and neither could express “only Azerbaijan” against it.citycould not substitute — it is deliberately an Azerbaijani-only closed vocabulary, so a posting in Warsaw is null there and no predicate asks for “any Azerbaijani city”.?location=could not either: it is an include match on free text, and most rows never name a country at all while the local spelling is Azərbaycan. So the geography constraint lived inside each scraper, which meant every source that could serve foreign roles had to be filtered forever and every consumer that wanted them was blocked. ISO 3166-1 alpha-2, and null means the source did not say — the same honesty rulecityfollows. It is set only where the source published a country, which is a minority of the corpus and fills as the fleet re-observes, so?country=AZis 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 on the country you get back rather than asking for one here.?country=refuses a code that is not a code, and names the right one when it can. This is the narrowing, and it is the one entry on this page that is not purely additive.?country=UKused to answer200with an empty page — andUKis not an ISO 3166-1 code at all; the United Kingdom isGB. An empty page for a mistyped code reads as “you hold nothing there”, which is exactly the sentence this filter exists to stop us saying by accident, and the caller has no way to tell that apart from a wrong string. SoUKis now a400that tells you the code isGB,ENis a400that says it is a language tag, and anything that is not two letters is a400naming what you sent — the same shape an unknown?source=has answered with since 2026-08-28. A code the standard does define but this corpus has no rows for still returns an empty page, because that answer is true — no posting carries it yet, and the column fills as sources re-observe. That is a different case fromZZ, which is refused at the door on the way in and so can never be stored at all; both answer200with nothing, and both are honest. One code per request, not a list.
On the second entry and the 90 days. Narrowing what a parameter accepts is in the breaking column, and this shipped with no notice at all — two days after ?country= itself went live on 2026-09-03, not in the same release. The basis is the third exception rather than the calendar: UK and EN are not in the 249-code vocabulary the column is constrained to, so no posting has ever carried either and no key can ever have received a row under one. What a caller loses is an empty page and what it gains is the reason. Had the parameter been live for weeks the argument would be the same but the risk would not, because by then someone would plausibly be branching on that empty page — which is why it was done now rather than queued.
2026-08-31
The same customer as the entry below, on the next two things they hit. One was a real defect on our side and had been for as long as the field has existed.
descriptionkeeps its paragraphs and its bullets. Every tag-stripper on the scraper side replaced an HTML tag with a space — right for an inline<strong>, wrong for everything else — so a body of twelve<li>bullets and three<p>paragraphs arrived as one flat line with no newline in it anywhere. It now converts block boundaries first:<br>to a newline,</p>and</div>to a blank line,<li>to a bulleted line, with at most one blank line between blocks as before. Three cosmetic defects reported alongside it are fixed indescriptionby the same pass: a bullet glued to its first word (•Proficiency), a list item that arrived with no bullet at all, and a space stranded before punctuation where an inline tag used to stand (Requirements :). Nothing about this field's meaning changed — if you were splitting this text yourself, you no longer have to; if you were rendering it as one paragraph, it will now break where the source broke it. (description_htmldid change in meaning, in the same release — see the next entry.) Rows fill as the fleet re-observes them, which is one cycle — eight hours — for anything still open. Postings whose source has already removed them keep the flat text they were stored with.source_urlon every job row, andurlandjobs_urlonGET /v1/sources. The response named a source asglorriand called itGlorri, and there was still nothing in it you could link to.source_urlis the source's own website —https://glorri.com,https://abb-bank.az— and it is what to pointsource_nameat./v1/sourcesaddsjobs_urlbeside it: the page we actually read that 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 and for 106 respectively. Null rather than guessed on the rest: three sources live entirely on a third party's applicant-tracking system and their employer's own domain appears nowhere in our scrapers — theirjobs_urlstill points at the page the postings are on — one has only its ATS vendor's domain recorded against it, one is a catch-all bucket with no website, the 21 sources with no scraper keep a site with no recorded listings page, and three we have measured as gone lose both halves: a hosting placeholder that answers every path, an organisation that no longer exists and redirects to a different agency, and a decommissioned ATS tenant. Their rows stay; their link goes. A wrong link is worse than an absent one, because you would render it.description_htmlandrequirements_htmlare now populated on every row that has a body — they were null on 87% of them, because 79 of our 109 scrapers never see any HTML: the source publishes a plain-text body, or a listing card and no reachable detail page at all. Read the provenance note, because there are now two of them and the field does not tell you which you have. Where the source published markup — about 30 of 130 sources — it is still that source's own, passed through, and generated markup never replaces it. Where the source published only text, the markup is ours, generated fromdescription: paragraphs become<p>, a run of bullet lines becomes a real<ul>/<li>rather than a picture of one, and everything is escaped. It carries no informationdescriptiondoes not — what it gives you is one shape to render whichever scraper produced the row, and a<ul>that JSON-LD can actually recognise. It round-trips: converting it back to text returns thedescriptionit came from. StillGET /v1/jobs/{id}only, and still no backfill — live rows fill within one fleet cycle, and a posting whose source already removed it keeps its null, for the reason recorded when the columns were first published.- Two of the three markup defects reported alongside are fixed in
description_htmltoo, on the pass-through half: a block element holding nothing but a space or an is dropped, and a bullet glued to its first word gets the space back. The third is deliberately not. Inserting a bullet into a list item that has none would be writing content into a field whose entire value is that the source wrote it — and in markup it is usually not a defect at all, because a real<ul>draws its own markers. The plain-textdescriptiondoes supply one there, which is where that defect was actually visible. qualityonGET /v1/sourcesis now in the field table on this page. It has been in the response since 2026-08-23 and was never written down here.
2026-08-28
All of this came out of one customer's question: “I only use non-job-board vacancies, and most of them have no contact_email or description, so the real count is very small.” Two thirds of it turned out to be true, and the parts that were true were ours to fix.
source_type, and?source_type=on/v1/jobs. Every source is now classifiedemployer,platform,agency,board,aggregatororunknown, onGET /v1/sourcesand as a comma-separated filter. So “everything that did not come from a job board” is?source_type=employer,platform,agency— one request instead of the fifty?source=calls it used to take. This is our editorial judgement, not a fact the source asserts; the values can change when a source is re-read.live_recordsonGET /v1/sources, becauserecordswas answering a different question than people were asking it.recordscounts every posting in the current-state table, retired ones included — 149,383 rows, of which 39,046 were still open when this shipped. Two sources report thousands and return nothing at all, their scrapers having stopped months ago.live_recordsis what?active=truereturns.recordsis unchanged, and its description no longer claims a suppression filter it never applied.- An unknown
?source=is now a400.?source=abb,azercell,?source=ABBand any misspelled stem used to answer200with an empty array — indistinguishable from “that employer has nothing open”. The filter takes one stem and always has; now it says so. Usesource_typeto select several sources at once. contact_emailnow also picks up an application mailbox a posting publishes in its own body — 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, sohr.aliyeva@does not qualify; the domain is the posting's own; and the source's url identifies one organisation — a direct employer or a named agency, never a job board, a multi-tenant careers platform, or an ATS domain shared between employers. Together those mean a named individual's address cannot qualify and neither can a third party's. On employer career pages that took coverage from 2% to 28%; ten distinct mailboxes account for every row it filled. Nothing else is read out of advert prose, andcontact_phoneis unchanged — a number has no local part, so there is no equivalent way to tell a switchboard from someone's mobile.GET /v1/sourcesis now defined in the published contract, not just referenced by it. It spans three datasets, so it had no home in a schema organised by dataset — the endpoint was mentioned fifteen times in field descriptions and its own fields were documented nowhere,source_typeandlive_recordsincluded./v1/schemagains asharedsection for endpoints that belong to no single dataset and need no key. Field-by-field reference: whatGET /v1/sourcesgives you.- Two published claims were wrong and are corrected, with the old wording quoted rather than deleted. The
activedescription illustrated the “no verdict” problem withjobsite_azholding “over 12,000 postings, all stilltrue”. It holds 12,667 rows of which 1,199 are active; the rest were retired on 2026-08-12, before that sentence was written — a corpus total relabelled as an active count. Andurlwas documented as non-unique in two ways but never as unstable over time: onjobpilot_remoteoneidemitted 39 distinct urls over five weeks for one unchanged posting, withjobAgeincrementing 24 per scrape, so the url is guaranteed to differ every daily run. Key onid;urlis a link that moves. - A handful of
contact_emailvalues that predate the guards were removed. The two conditions above apply to everything this platform writes, but a small number of rows imported from the upstream board's database carried an address that board had extracted from advert text under different rules — including one belonging to a named individual. Anything whose local part is not a role name is now cleared, on the same rule the forward path applies. Twelve institutional mailboxes were unaffected. - Four published coverage figures were wrong and are now measured. Counted over a complete walk of every live posting rather than a sample:
cityis 25%, not “about 60% and climbing” — it fills only from the ~20 sources that publish a structured place, and it is not heading for 60%.descriptionis 92% (97% on non-board postings),requirements11% rather than 5%,contact_email~6% rather than 4%. The number that matters more than any of them: 19 in 20contact_emailrows are on four job boards, so filtering to direct employers and needing an address pull against each other.
2026-08-23
- Corrected what
activeclaims. Its description said the field told you whether the source was still publishing a posting; that is only true when the source has recently run, and for 15 of 96 sources it has not.trueis the default state and means “no verdict”. No behaviour changed — the documentation was overstating a field that always worked this way. Deciding liveness yourself is new, and is what to use instead. - Conditional requests. Every
200carries anETag;If-None-Matchgets you a304with no body, and no credit is charged. X-Quota-UsedandX-Quota-Remainingno longer count a request that was never billed. A404used to advertise a credit it did not take.
2026-08-22
410 gone— a seventh error code. A single-resource fetch for a record removed at someone's request now says so instead of answering404. Only for ids this platform actually held.GET /v1/sources— every source with its dataset, row count and last batch time. No key needed. (It gainedsource_typeandlive_recordson 2026-08-28.)salary_period—HOUR/DAY/WEEK/MONTH/YEAR, and null unless the source stated one.cityanddistrict, with?city=and?district=filters. Besidelocation, which is unchanged.description_htmlandrequirements_htmlonGET /v1/jobs/{id}only.work_type,company_logo,view_count,contact_email,contact_phonepromoted out offactsinto typed fields.- Acceptable-use policy published, at /docs/shared/use.
2026-08-21
activeanddeactivated_aton jobs. A posting whose source stopped publishing it is marked rather than deleted.?active=defaults toany— see Queued.updated_aftermatchesdeactivated_attoo, so a mirror is told when a posting dies.source_nameon every record — the source as a person would write it.- Pay and deadline filters:
has_salary,salary_from_at_least,salary_currency,deadline_after. updated_afteron/v1/events;slugon events.- Free plan raised from 50 to 1,000 requests a month.
- A credit is now one successful request —
4xxand5xxstopped being billed.
Before that
v1 has been serving jobs, events and quotes since March 2026. Changes before this log started were not tracked, which is the gap this page exists to close — everything from here is recorded.