This is the full developer documentation for Backspin Jobs API # Backspin Jobs API > Job postings straight from employers' own hiring systems, kept in sync for your job board. Backspin collects job postings from US employers’ own hiring systems, like Workday and Greenhouse, and gives your job board one clean record per job. Nothing comes from LinkedIn, Indeed or other job boards. The Jobs API lets your board pull the jobs it covers once, then stay in sync by asking what changed. * **Base URL:** `https://app.backspin.io/v1` * **Format:** JSON, `GET` only * **Auth:** one API key per board, sent as `Authorization: Bearer bsk_live_…` * **Reference:** every endpoint, parameter and field in the [API reference](/docs/reference/), from the [OpenAPI spec](https://app.backspin.io/v1/openapi.json) * **For AI coding tools:** [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt) ## What a job record gives you [Section titled “What a job record gives you”](#what-a-job-record-gives-you) Title, company, locations, pay, remote, hybrid or on-site, employment type, the employer’s apply link and the full description. Every value is what the employer published or what Backspin read from the job’s own text, and each one says which (`from`). Unknown values are `null`; nothing is guessed. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Quickstart](/docs/quickstart/): get a key and make your first request. * [Search jobs](/docs/jobs/): filters and paging. * [Changes feed](/docs/changes/): keep your board in sync with one call an hour. # Authentication > API keys, how to send them, and what happens when they're wrong. Send your key in the `Authorization` header, and only there: ```http GET /v1/jobs HTTP/1.1 Host: app.backspin.io Authorization: Bearer bsk_live_… ``` * Keys are `bsk_live_` followed by 43 letters and digits. Backspin stores only a hash, so a lost key can’t be shown again: revoke it and create another. * Keys are read-only and belong to one board. Create and revoke them on the API keys page at [app.backspin.io/keys](https://app.backspin.io/keys). * A revoked key stops working within a minute. * Keys in the query string are ignored, and a dashboard session cookie doesn’t work on `/v1`. * A missing, malformed, unknown or revoked key gets the same `401`. # Changes feed > GET /v1/changes tells your board what was added, changed or closed since it last asked. ```http GET /v1/changes?since={next_since} ``` * **`since`** is the `next_since` from your previous call. Leave it out once, to start from now. * **Any [search filter](/docs/jobs/#filters), and `fields`,** decide `matches` for each change. They never hide a change. * **`limit`** is 1 to 500 changes, 500 by default. Every parameter is in the [API reference](/docs/reference/operations/listchanges/). Returns changes oldest first: ```json { "changes": [ { "change": "updated", "id": "bsj_8f3k2m9q", "matches": true, "job": { } }, { "change": "updated", "id": "bsj_8f3k2mzz", "matches": false, "job": null }, { "change": "closed", "id": "bsj_8f3k2n01", "matches": false, "job": null } ], "next_since": "…", "more": false } ``` * **`change`** is `created`, `closed`, or `updated` (anything else a board can see changed, including a job reopening). * **`matches`** is true when the job, as it is now, is open and passes your filters. A closed job never matches. * **`job`** is the full record when `matches` is true, otherwise `null`. * Every change is listed whatever your filters, so you hear about a job that left your filter or closed, not only jobs that match. * A job that changed several times appears once, in its latest state. * Call again with `next_since` until `more` is `false`. A page can be short, even empty, with `more: true`. * `since` values never expire. A board that was away for a month just pages through more changes. * A `since` this feed never handed out is a `409`: start again from the first run. Changes appear in the feed within about 15 seconds. ## The sync loop [Section titled “The sync loop”](#the-sync-loop) ```text first run: s = next_since from /v1/changes (no since) page through /v1/jobs?category=electrical&country=US, upsert every job every hour: /v1/changes?since=s&category=electrical&country=US, until more is false matches: upsert the job and show it not matches (including every closed job): hide it if you have it s = next_since ``` Always upsert by `id`: a `created` change can arrive for a job you already stored from your first pull. Poll once an hour; once a day at the same hour also works. Backspin rechecks each employer about every six hours, so polling more often mostly returns nothing. # One job > GET /v1/jobs/{id} and the shape of a job record. ```http GET /v1/jobs/{id} ``` Returns one job, including closed ones (`status: "closed"` with `closed_at`). An unknown or malformed id is a `404`. Ids are Backspin’s own, `bsj_` followed by lowercase letters and digits, and never change. ## The job record [Section titled “The job record”](#the-job-record) Every field is in the [API reference](/docs/reference/operations/getjob/). Illustrative values: ```json { "id": "bsj_8f3k2m9q", "status": "open", "title": "Fire Alarm Technician", "company": { "name": "Pye-Barker Fire & Safety", "domain": "pyebarkerfs.com" }, "description_html": "

…

", "apply_url": "https://pyebarkerfire.wd1.myworkdayjobs.com/en-US/PB_V2/job/…", "locations": [ { "text": "Denver, CO", "city": "Denver", "region": "CO", "country": "US" } ], "workplace": { "value": "onsite", "from": "ats" }, "employment_type": { "value": "full_time", "from": "ats" }, "pay": { "min": 32, "max": 42, "currency": "USD", "period": "hour", "from": "ats", "period_from": "ats", "text": null }, "flags": { "commission_only": false, "door_to_door": false, "retail_sales": false }, "department": "Service", "seniority": null, "categories": ["electrical"], "metros": ["denver"], "posted_at": "2026-10-28T14:02:00Z", "first_seen_at": "2026-10-28T18:00:00Z", "updated_at": "2026-10-30T06:00:00Z", "closed_at": null, "source": { "platform": "workday", "job_id": "JR113627" } } ``` ## How to read it [Section titled “How to read it”](#how-to-read-it) * **`from`** says where a value came from: `ats` (the employer’s own field), `title`, `location`, `description`, or `amount` (a pay period judged from the size of the amounts). * **Unknown is `null`.** `workplace`, `employment_type` and `pay` are always objects; when nothing is known their members are `null`. * **Pay** comes whole from one side, never mixed. When it was read from the description, `text` is the exact text it came from. * **`flags`** are `true` when the job’s own text says so. `false` means not stated, not stated otherwise. * **`apply_url`** is the employer’s job page. Boards link out; Backspin never takes applications. * **`description_html`** is the employer’s HTML as published. Sanitize it before you render it. * **`updated_at`** moves only when the listing itself changes. * **`company.domain`** is filled once Backspin knows it; most are empty today. # Search jobs > GET /v1/jobs returns open jobs that match your filters, newest first. ```http GET /v1/jobs ``` Returns `{"jobs": [Job…], "next_cursor": "…" | null}`, newest first (by when Backspin first saw the job). Pages are stable: a job open for your whole walk appears exactly once. Jobs added while you page show up in the [changes feed](/docs/changes/). Closed jobs never appear here, and neither do jobs from boards that are disabled or haven’t crawled successfully for 14 days. ## Filters [Section titled “Filters”](#filters) Every parameter, with its allowed values, is in the [API reference](/docs/reference/operations/listjobs/). All are optional and combine with AND. A few work in ways worth knowing: * **`category`, `exclude` and `pay_period`** can be repeated. Repeated `category` or `pay_period` values match any of them; repeated `exclude` values leave out each one. * **`country` and `region`** match any of the job’s locations. Given both, they must match the same location. * **`q`** matches jobs whose title or company name contains every word, up to 10 words. * **`posted_since`** counts a job with no stated posting date from when Backspin first saw it. * **Pay filters** look at the job’s own pay, from the employer’s pay field or read from the description (`pay_from` picks one). Jobs without pay never match `pay_period`, `pay_currency` or `pay_at_least`, but stay in when you `exclude=hourly_pay`. * **`pay_at_least`** compares the top of the range (max, else min) in the job’s own period and currency, so it needs exactly one `pay_period` and a `pay_currency`. Backspin never converts hourly to yearly. * **`fields=summary`** sets `description_html` to `null` for a much smaller answer. * **`limit`** is 1 to 500, 100 by default. Pass `next_cursor` as `cursor` for the next page. An unknown, repeated (other than `category`, `exclude` and `pay_period`), empty or invalid parameter is a `422` that names it, so a typo never widens your query. ## Examples [Section titled “Examples”](#examples) Electrician jobs with pay: ```http GET /v1/jobs?category=electrical&country=US&has_pay=true ``` Account executive jobs with a base salary of at least $100,000 and no commission-only, door-to-door or retail sales: ```http GET /v1/jobs?category=account_executive&country=US&pay_period=year&pay_currency=USD&pay_at_least=100000&exclude=commission_only&exclude=door_to_door&exclude=retail_sales ``` # Opening and closing > When a job appears, changes and closes. * **New.** A job appears once a crawl of its employer’s board lists it. * **Changed.** `updated_at` moves only when the listing itself changes. A crawl that just sees the job again changes nothing. * **Closed.** A job closes after two complete crawls of its board in a row don’t list it. A failed crawl never closes anything, and neither does a board that suddenly lists zero jobs. * **Disabled boards.** Disabling a board closes its open jobs at once. * **Stale boards.** A board that hasn’t crawled successfully for 14 days has its jobs leave search right away; they reach the changes feed as `closed`. * **Reopened.** When a disabled or stale board crawls successfully again, the jobs it still lists reopen (`updated`, `status: "open"`). * **Never deleted.** A closed job’s id keeps working with [`/v1/jobs/{id}`](/docs/job/). # Limits and errors > Rate limits, daily caps and the error format. ## Limits [Section titled “Limits”](#limits) | Limit | Value | Answer | | ------------------------------------ | ------------------------- | ------------------------------------------- | | Requests per key | 10 a second, bursts of 20 | `429` with `Retry-After` in seconds | | Requests per key per day (UTC) | 100,000 | `429` with `Retry-After` until midnight UTC | | Failed key checks per client address | 1 a second, bursts of 20 | `429` with `Retry-After: 60` | | Time per request | 30 seconds | `500` | Your requests per day, per key, are on the API keys page. ## Errors [Section titled “Errors”](#errors) Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem JSON: ```json { "status": 401, "title": "Unauthorized", "detail": "…" } ``` | Status | When | | ------ | ---------------------------------------------------------------------------------- | | `401` | The key is missing, malformed, unknown or revoked. | | `404` | No job with that id. | | `405` | Any method other than `GET`. | | `409` | A `since` the changes feed never handed out. | | `422` | An unknown, repeated, empty or invalid parameter. The answer names it. | | `429` | Too many requests, or too many bad keys from your address. Wait for `Retry-After`. | | `500` | Something went wrong on our side, or the request ran past 30 seconds. | | `503` | Backspin is briefly busy. Retry after `Retry-After` seconds. | Responses carry `Cache-Control: no-store`. # Quickstart > Get an API key, pull your first jobs and keep them in sync. ## 1. Get an API key [Section titled “1. Get an API key”](#1-get-an-api-key) 1. [Sign up](/sign-up) with your email or Google. 2. Tell us your company website. 3. Enter your access code. Access is by access code for now; email to ask for one. 4. On the API keys page, create a key named after your board. It’s shown once, so store it in your board’s server secrets. Keep the key on your server. The API sends no CORS headers, so it can’t be called from a browser. ## 2. Pull the jobs your board covers [Section titled “2. Pull the jobs your board covers”](#2-pull-the-jobs-your-board-covers) ```sh curl "https://app.backspin.io/v1/jobs?category=electrical&country=US&has_pay=true" \ -H "Authorization: Bearer $BACKSPIN_API_KEY" ``` You get up to 100 jobs (up to 500 with `limit=500`) and a `next_cursor`. Call again with `cursor=` until it’s `null`, and upsert every job by `id`. ## 3. Stay in sync [Section titled “3. Stay in sync”](#3-stay-in-sync) Before that first pull, call `/v1/changes` once without `since` and keep its `next_since`. Then, once an hour: ```sh curl "https://app.backspin.io/v1/changes?since=$SINCE&category=electrical&country=US" \ -H "Authorization: Bearer $BACKSPIN_API_KEY" ``` * `matches: true`: upsert the job and show it. * `matches: false` (including every closed job): hide it if you have it. * Keep calling with the new `next_since` until `more` is `false`. See [Changes feed](/docs/changes/) for the details. # Where jobs come from > The hiring systems Backspin reads, and how. Backspin reads job postings from employers’ own applicant tracking systems (ATS), the software employers post jobs in. It never takes jobs from LinkedIn, Indeed or other job boards. ## Hiring systems we read [Section titled “Hiring systems we read”](#hiring-systems-we-read) Greenhouse, Workday, Lever, iCIMS, Oracle Recruiting Cloud, ADP Workforce Now, Ashby, UKG (UltiPro), SmartRecruiters, Workable, Rippling, BambooHR, Paylocity, JazzHR, Recruitee, Pinpoint, Breezy HR and Teamtailor. ## How we read them [Section titled “How we read them”](#how-we-read-them) * Only public job pages and feeds, with no sign-in. Backspin never gets past bot protection, solves CAPTCHAs or ignores a site’s access rules. * Every request names Backspin in its User-Agent, sends at most 2 requests a second to a host, and backs off when a site asks it to. * Each employer’s board is rechecked about every six hours. * A crawl that misses any page fails as a whole, so a partial list never looks complete. * Backspin fills in only what the employer states, or what the job’s own text says, and records which.