Developers
Jobs Unfiltered API
Sync jobs, applicants, stages, messages and new hires with your HR system, ATS or spreadsheet, and get a signed webhook the moment something happens. The API follows the same rules as the website: honest salary ranges, the hiring process on every listing, and every stage change visible to the candidate.
Quick start
You need a verified company on the Company plan. Then:
- Go to Hiring → API & integrations and create a key. Tick allow writes if you'll post jobs, move applicants or send messages. Copy the key straight away: it's only shown once.
- Check it works:
You'll get your company and the key's name back.curl https://jobsunfiltered.com/api/v1/me \ -H "Authorization: Bearer $JU_API_KEY" - Pull your applicants:
curl "https://jobsunfiltered.com/api/v1/applications?limit=50" \ -H "Authorization: Bearer $JU_API_KEY"
Every endpoint, field and error is also in the OpenAPI spec, which you can load into Postman, Insomnia or a code generator.
Keys
- Read-only or read and write. A read-only key gets
403 read_only_keyon anything that changes data. Use read-only keys wherever you can. - Shown once. We keep only a hash, so a lost key can't be recovered. Create a new one and revoke the old one.
- One key per integration, so you can revoke one without breaking the others. Revoking takes effect immediately.
- Tied to a person. Messages sent with a key are sent as the person who created it. If they delete their account, their keys stop working.
- Send the key on every request as
Authorization: Bearer ju_live_…. Being signed in to the website doesn't count: the API only accepts keys.
Requests and limits
- Base address:
https://jobsunfiltered.com/api/v1. JSON in and out. SendContent-Type: application/jsonwith a body. Paths work with or without a trailing slash,HEADworks whereverGETdoes, and an unknown path answers404 not_foundin JSON like every other error. - Times are ISO 8601 in UTC, for example
2026-10-15T09:30:00+00:00. Money is a whole number in the stated currency and period. - Pagination: lists come newest first, 50 at a time (
limitup to 100). Each page has anext_cursor; pass it back ascursorfor the next page. It'snullon the last page. - Syncing changes:
GET /applications?updated_since=…returns only what changed since your last sync. Store the time you started each sync and use it next time. - Rate limit: 1200 requests an hour per key. Every response tells you where you are:
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(seconds until the hour resets). Over the limit you get429 rate_limitedwith aRetry-Afterheader. - Privacy by design: reading an application through the API never marks it as "opened" for the candidate. Opened means a person looked.
Errors
Errors share one shape, with an HTTP status that matches:
{"error": {"code": "validation_failed", "message": "Some fields need fixing.",
"fields": {"salary_max": [{"message": "…", "code": "invalid"}]}}}
Switch on code, not on the message text. Every code the API can return:
| Status | Code | What it means |
|---|---|---|
| 400 | bad_json | The body isn't a JSON object. |
| 400 | use_offer | Offers can only be made in Jobs Unfiltered, so the candidate sees the written terms. |
| 400 | validation_failed | A field is missing or invalid. Job validation errors are listed per field in `fields`. |
| 401 | invalid_key | The key is wrong or has been revoked. |
| 401 | unauthenticated | No Authorization header, or not a Bearer token. |
| 403 | plan_required | The company isn't verified, or isn't on the Company plan. |
| 403 | read_only_key | A write was attempted with a read-only key. |
| 404 | no_cv | The application has no CV (deleted under the retention policy, or never uploaded). |
| 404 | no_thread | The application has no message thread. |
| 404 | not_found | No such object for your company (other companies' objects are never visible). |
| 405 | method_not_allowed | That HTTP method isn't supported on this path. |
| 409 | already_closed | The job is already closed. |
| 409 | needs_accepted_offer | `hired` can only follow a written offer the candidate accepted. |
| 409 | no_sender | The person who created this key has deleted their account. Create a new key. |
| 409 | not_open | The application already has its outcome (hired, declined, withdrawn or closed). |
| 429 | rate_limited | Over the hourly limit for this key. Wait `Retry-After` seconds. |
Endpoints
| Method and path | What it does |
|---|---|
GET /me | Your company and this key |
GET /jobs | Your jobs. Filter with status=open|paused|closed |
POST /jobs write | Post a job, with the same checks as the website |
GET /jobs/{id} | One job, including its apply link |
PATCH /jobs/{id} write | Change a job. Send only the fields that change |
POST /jobs/{id}/close write | Close with an outcome. Everyone still waiting is told |
GET /applications | Applications to your jobs. Filter with job_id, status, updated_since |
GET /applications/{id} | Profile, fit, CV scan result, full history and offer |
POST /applications/{id}/status write | Move to another stage. The note is shown to the candidate |
GET /applications/{id}/cv | The CV as a PDF, cleaned of scripts and hidden content when it was uploaded |
GET /applications/{id}/messages | The message thread, oldest first |
POST /applications/{id}/messages write | Message the candidate. Counts as a reply on your record |
GET /hires | Confirmed hires with the written offer, start date and onboarding checklist |
GET /stats | Your public track record and private hiring insights |
Post a job
curl -X POST https://jobsunfiltered.com/api/v1/jobs \
-H "Authorization: Bearer $JU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Data Analyst", "category": "tech", "level": "mid",
"location": "Leeds", "work_mode": "hybrid",
"salary_min": 40000, "salary_max": 50000,
"skills": ["SQL", "Python"],
"description": "What the job is, day to day."}'
Required: title, category, level, location, work_mode, salary_min, salary_max, description. Anything you leave out takes the same default as the website:
employment_type=full_time, currency=GBP, salary_period=year, interview_stages=2, decision_days=14, takehome_hours=0.
The range can be at most 2.5 times its minimum, take-home work is capped at 8 hours, and agencies must name the client in hiring_for_client. Allowed values for each field are in the spec. The response is the job, with 201 Created.
Sync applicants into your system
# Python: everything that changed since the last run
import requests
def sync(key, since):
url, params, out = "https://jobsunfiltered.com/api/v1/applications", {"updated_since": since, "limit": 100}, []
while True:
r = requests.get(url, params=params, headers={"Authorization": f"Bearer {key}"}, timeout=30)
r.raise_for_status()
page = r.json()
out += page["data"]
if not page["next_cursor"]:
return out
params["cursor"] = page["next_cursor"]
Move an applicant
curl -X POST https://jobsunfiltered.com/api/v1/applications/123/status \
-H "Authorization: Bearer $JU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "declined", "note": "We went with someone with more NHS experience. Thank you for applying."}'
The response is the full application with its updated history.
Message an applicant
curl -X POST https://jobsunfiltered.com/api/v1/applications/123/messages \
-H "Authorization: Bearer $JU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body": "Could you talk on Thursday afternoon?"}'
Download a CV
curl -o cv.pdf https://jobsunfiltered.com/api/v1/applications/123/cv \
-H "Authorization: Bearer $JU_API_KEY"
Each application's cv block also says whether hidden text (white text, microscopic text, prompt-injection phrases) was found in the file, and shows it.
Stages and rules
An application moves through: submitted → viewed → reviewing → shortlisted → interviewing → offered → hired. It can also end as declined, withdrawn (by the candidate) or closed (the role closed).
- You can set
reviewing,shortlisted,interviewinganddeclinedwithPOST /applications/{id}/status. - Offers are made in Jobs Unfiltered, so the candidate sees the written terms (
use_offer). hiredhappens when the candidate accepts your written offer. Setting it directly needs an accepted offer (needs_accepted_offer).- Outcomes are final. Once an application is hired, declined, withdrawn or closed it can't be moved again (
not_open). - Your first real reply (a stage change or a message) is what counts towards your public reply rate. Reading doesn't.
Apply links
Every job has an apply_url. Paste it anywhere you advertise the role: LinkedIn (choose On an external website instead of Easy Apply), Indeed, your careers page, a newsletter. People apply through Jobs Unfiltered, and you see where each one came from.
Add a site name to the end to tag where applicants came from, for example https://jobsunfiltered.com/apply/42/linkedin/ or …/apply/42/indeed/. Any short lowercase tag works. Each application's source field then says linkedin, indeed and so on, or null if they found the job on Jobs Unfiltered. Ready-made links for the main sites are on each job's applicants page.
Webhooks
Add an HTTPS endpoint under API & integrations, pick the events you want, and copy the signing secret (shown once). Use the Test button to send a ping. Events:
| Event | When |
|---|---|
application.created | A new application arrives |
application.status_changed | An application moves stage (including withdrawals) |
offer.accepted | A candidate accepts your written offer |
offer.declined | A candidate declines your written offer |
hire.onboarding_ready | A hire is confirmed and their onboarding plan is open |
job.closed | A listing closes (with its outcome) |
intro.accepted | Someone accepts your intro request |
Every delivery is a POST with this shape. The data for each event is described in the spec's webhooks section. Application events carry the same fields as GET /applications; job.closed carries the job; hire.onboarding_ready carries the hire.
{"id": "evt_3f9c…", "type": "application.status_changed", "created": "2026-10-15T09:30:00+00:00",
"data": {"id": 123, "job_id": 42, "status": "interviewing", "from": "shortlisted", "to": "interviewing",
"note": "See you Tuesday", "source": "linkedin", …}}
Headers: Jobs-Unfiltered-Event, Jobs-Unfiltered-Delivery and Jobs-Unfiltered-Signature: t=<unix seconds>,v1=<hex>. Always check the signature against the raw body before trusting it, and reject timestamps more than five minutes old:
# Python
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
// Node
const crypto = require("crypto");
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
- Reply with any 2xx within 5 seconds, then do the work. Anything else counts as a failure.
- Failures are retried, up to 5 attempts within 7 days. Use
idto ignore a delivery you've already handled. - Endpoints must be public HTTPS on port 443 or 8443. We don't follow redirects, and we never call private or internal addresses.
- Webhooks are part of the Company plan. If the plan lapses, nothing is sent, and you can still pause or delete endpoints and revoke keys.
Using the data fairly
Data from the API may only be used to recruit and onboard for the role the person applied to, or the intro they accepted. No bulk export for other purposes, no reselling, and no training models on it. Once it's in your systems you're responsible for it under UK GDPR, including deleting it when you no longer need it. See the terms.