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.

Create a key OpenAPI 3.1 spec

Quick start

You need a verified company on the Company plan. Then:

  1. 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.
  2. Check it works:
    curl https://jobsunfiltered.com/api/v1/me \
      -H "Authorization: Bearer $JU_API_KEY"
    You'll get your company and the key's name back.
  3. 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

Requests and limits

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:

StatusCodeWhat it means
400bad_jsonThe body isn't a JSON object.
400use_offerOffers can only be made in Jobs Unfiltered, so the candidate sees the written terms.
400validation_failedA field is missing or invalid. Job validation errors are listed per field in `fields`.
401invalid_keyThe key is wrong or has been revoked.
401unauthenticatedNo Authorization header, or not a Bearer token.
403plan_requiredThe company isn't verified, or isn't on the Company plan.
403read_only_keyA write was attempted with a read-only key.
404no_cvThe application has no CV (deleted under the retention policy, or never uploaded).
404no_threadThe application has no message thread.
404not_foundNo such object for your company (other companies' objects are never visible).
405method_not_allowedThat HTTP method isn't supported on this path.
409already_closedThe job is already closed.
409needs_accepted_offer`hired` can only follow a written offer the candidate accepted.
409no_senderThe person who created this key has deleted their account. Create a new key.
409not_openThe application already has its outcome (hired, declined, withdrawn or closed).
429rate_limitedOver the hourly limit for this key. Wait `Retry-After` seconds.

Endpoints

Method and pathWhat it does
GET /meYour company and this key
GET /jobsYour jobs. Filter with status=open|paused|closed
POST /jobs writePost a job, with the same checks as the website
GET /jobs/{id}One job, including its apply link
PATCH /jobs/{id} writeChange a job. Send only the fields that change
POST /jobs/{id}/close writeClose with an outcome. Everyone still waiting is told
GET /applicationsApplications 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 writeMove to another stage. The note is shown to the candidate
GET /applications/{id}/cvThe CV as a PDF, cleaned of scripts and hidden content when it was uploaded
GET /applications/{id}/messagesThe message thread, oldest first
POST /applications/{id}/messages writeMessage the candidate. Counts as a reply on your record
GET /hiresConfirmed hires with the written offer, start date and onboarding checklist
GET /statsYour 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).

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:

EventWhen
application.createdA new application arrives
application.status_changedAn application moves stage (including withdrawals)
offer.acceptedA candidate accepts your written offer
offer.declinedA candidate declines your written offer
hire.onboarding_readyA hire is confirmed and their onboarding plan is open
job.closedA listing closes (with its outcome)
intro.acceptedSomeone 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));
}

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.