Skip to content

Ashby API Guide: Authentication, Candidates, Resumes and Webhooks

How the Ashby API works: API key auth and permissions, candidate.list sync tokens, resume downloads via file.info, webhooks, and parsing resumes to JSON.

Published · 5 minutes read

Cover reading “Ashby API guide for developers” with a candidate icon and a code icon

The Ashby API is an RPC-style HTTP API at https://api.ashbyhq.com where you POST a JSON body to endpoints such as candidate.list. You authenticate with an API key sent as the HTTP Basic auth username with an empty password, and each key only reaches the modules an Ashby admin granted it. With it you can pull candidates, applications, jobs and resume files, sync changes incrementally and receive webhooks.

Base URLhttps://api.ashbyhq.com/CATEGORY.method, POST + JSON body
AuthAPI key as Basic auth username, empty password; server-side only (no CORS)
Version2026-01-01; pinned per key, override with the Ashby-Version header
ErrorsHTTP 200 with success: false and errorInfo.code
Paginationcursor / nextCursor, 100 per page by default; syncToken for incremental sync (valid 14 days)
Rate limitsPer API key, 429 when exceeded
WebhooksHMAC-SHA256 in Ashby-Signature, up to 10 retries
Resume filesresumeFileHandle → file.info → download URL
Docsdevelopers.ashbyhq.com, checked October 29, 2026

1. Create an API key

Only Organization Admins can create keys, under Admin > Integrations > API Credentials. A new key starts with no permissions, and it is shown once, so copy it before closing the wizard. For a resume workflow, grant:

  • Candidates read (candidatesRead) for candidate.list, candidate.info, application.list and file.info.
  • Candidates write (candidatesWrite) only if you post results back with candidate.createNote.
  • Confidential jobs and private fields: off by default, so tick them if those candidates must be included.

The key goes in the username slot of Basic auth, so with curl it's -u "$ASHBY_API_KEY:". Keep it on your server: Ashby doesn't enable CORS, so proxy any front-end call through your backend. A missing key returns 401; a wrong or deactivated one 403, and so does a missing permission (with missing_endpoint_permission).

2. List candidates with pagination

Every list endpoint returns results, moreDataAvailable and nextCursor. Send the cursor back until moreDataAvailable is false; the last page carries a syncToken that gets you only the records changed since.

Don't trust the status code alone. A rejected request usually comes back as HTTP 200 with success: false and an errorInfo holding a code, a message and a requestId to log. A 429 that carries X-RateLimit-Scope: integration-partner means a partner's combined traffic was throttled, not your key alone.

The one thing to watch: tokens and cursors expire after 14 days, and an incremental sync longer than 100 pages fails with incremental_sync_too_large. In practice, run it at least daily and restart from scratch on the documented error codes, wich is what this client does.

# ashby.py: call the Ashby API and sync candidates page by page (Python 3.10+).
import os
import time
import requests

session = requests.Session()
session.auth = (os.environ["ASHBY_API_KEY"], "")  # key as username, empty password
session.headers.update({"Accept": "application/json", "Content-Type": "application/json"})
RESTART = {"sync_token_expired", "sync_token_invalid", "incremental_sync_too_large",
           "next_cursor_expired", "cursor_invalid", "invalid_next_cursor"}  # drop token, start over

def ashby(method: str, payload: dict | None = None) -> dict:
    for attempt in range(5):
        r = session.post(f"https://api.ashbyhq.com/{method}", json=payload or {}, timeout=30)
        if r.status_code == 429 or r.status_code >= 500:
            time.sleep(2**attempt)  # back off, then retry
            continue
        r.raise_for_status()  # 401 missing key, 403 wrong key or missing permission
        body = r.json()
        if not body.get("success"):  # client errors arrive as HTTP 200
            raise RuntimeError((body.get("errorInfo") or {}).get("code", "ashby_error"))
        return body
    raise RuntimeError(f"{method} still failing after 5 attempts")

def sync_candidates(sync_token: str | None, handle) -> str | None:
    cursor = None
    while True:
        payload = {"limit": 100, "syncToken": sync_token, "cursor": cursor}
        try:
            page = ashby("candidate.list", {k: v for k, v in payload.items() if v})
        except RuntimeError as error:
            if str(error) not in RESTART or not (sync_token or cursor):
                raise
            sync_token = cursor = None  # clean full sync
            continue
        for candidate in page["results"]:
            handle(candidate)
        if not page.get("moreDataAvailable"):
            return page.get("syncToken")  # only on the last page: store it for next run
        cursor = page["nextCursor"]

application.list works the same way and also filters by jobId, status, createdAfter and createdBefore.

3. Download the resume

Each candidate has a resumeFileHandle (or null). Pass its handle string to file.info, then download the returned url right away rather than storing it. Other attachments sit in the fileHandles array and work the same way.

# resume_file.py: turn a candidate's resume file handle into bytes.
import requests

from ashby import ashby


def download_resume(candidate: dict) -> tuple[str, bytes] | None:
    handle = candidate.get("resumeFileHandle")  # {"id", "name", "handle"} or None
    if not handle:
        return None
    url = ashby("file.info", {"fileHandle": handle["handle"]})["results"]["url"]
    r = requests.get(url, timeout=60)  # plain GET: don't send Ashby credentials here
    r.raise_for_status()
    return handle["name"], r.content

Heads-up: file.info can fail on the generated demo data in demo accounts. Honestly, a test candidate with a real resume you uploaded is the easier thing to develop against.

4. Parse it with CV Extract

Ashby stores the file but doesn't give you the work history or skills inside it as fields. The HireLayer CV Extract API takes the file in one multipart request with an X-API-Key header and returns the candidate as JSON in the same response, for 1 credit per successful parse (the Free plan includes 50 credits a month).

# parse.py: send the resume to HireLayer CV Extract and get JSON back.
import os

import requests


def parse_resume(file_name: str, content: bytes, candidate_id: str) -> dict | None:
    r = requests.post(
        "https://hirelayer.co/api/v3/parser",
        headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
        files={"file": (file_name, content)},
        data={"application_id": f"ashby:{candidate_id}"},  # echoed in info_resume
        timeout=150,  # usually about 35 s; the gateway returns 504 after 145 s
    )
    if r.status_code in (400, 413, 415):
        return None  # bad, too large or unsupported file: don't resend it unchanged
    r.raise_for_status()  # 401 bad key, 403 no credits left, 5xx: retry later
    return r.json()

The response holds info_candidate (name, email, phone, location, LinkedIn, job title, experience bracket), work_experiences and educations with normalized dates, languages with CEFR-based levels and typed skills, from PDF, Word or image files (scans included) in 70 languages. Below is a trimmed respone: sending ashby: plus the candidate id as application_id ties every parse back to its Ashby record.

{
  "info_resume": { "application_id": "ashby:3ae2b801-...", "language": "EN" },
  "info_candidate": {
    "full_name": "Alex Morgan",
    "job_title": "Senior Software Engineer",
    "experience_level": "5 to 10 years"
  },
  "work_experiences": [{ "company_name": "Northstar Labs", "start_date": "2022-03-01", "currently_active": true }],
  "skills": [{ "skill_title": "TypeScript", "skill_type": "Software skill", "status": "normalized" }]
}

Skip candidates whose resume file id you already parsed, so an unrelated update doesn't cost another credit. For a large first import, the bulk parsing guide covers concurrency and duplicates. On errors, follow the error reference: retry 5xx with backoff, never resend a 4xx unchanged.

To show results inside Ashby, candidate.createNote takes a candidateId and a note (plain text or HTML), and customField.setValue fills a custom field you created, such as a skills list. Both need Candidates write.

5. Keep in sync with webhooks

For new applicants, create a webhook under Admin > Integrations > Webhooks on the applicationSubmit event, with a secret token. It doesn't fire for bulk imports or integration syncs, so keep the daily sync as a safty net. You can also register it from code with webhook.create, which needs the API Keys write permission.

  • Verify Ashby-Signature (HMAC-SHA256 of the raw body) in constant time.
  • Answer the ping Ashby sends on save, or the webhook stays disabled.
  • Reply fast and parse in the background: failed deliveries are retried up to 10 times, except on 401, 403, 404, 405 and 410.
# webhook.py: parse resumes as applications arrive (pip install flask requests).
import hashlib
import hmac
import os
from concurrent.futures import ThreadPoolExecutor

from flask import Flask, abort, request

from ashby import ashby
from parse import parse_resume
from resume_file import download_resume

app = Flask(__name__)
SECRET = os.environ["ASHBY_WEBHOOK_SECRET"].encode()
workers = ThreadPoolExecutor(max_workers=4)


def process(candidate_id: str) -> None:
    candidate = ashby("candidate.info", {"id": candidate_id})["results"]
    resume = download_resume(candidate)
    if resume:
        parsed = parse_resume(*resume, candidate_id)
        # store parsed, or post a summary with candidate.createNote


@app.post("/webhooks/ashby")
def ashby_webhook():
    raw = request.get_data()  # verify the raw body, before any JSON parsing
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("Ashby-Signature", "")):
        abort(401)
    event = request.get_json()
    if event.get("action") == "applicationSubmit":
        workers.submit(process, event["data"]["application"]["candidate"]["id"])
    return "", 200  # answer now, parse in the background

Bonus: published jobs without a key

Ashby's read-only Job Postings API serves a job board's published postings at GET https://api.ashbyhq.com/posting-api/job-board/ plus your board name, with no API key in the documented request. Feed the descriptionPlain field to HireLayer Job Extract if you want each job's requirements as data.

Parse your Ashby resumes into JSON

Try CV Extract on a resume in the live demo, then plug the endpoint into your Ashby sync. The Free plan includes 50 credits a month.

Frequently asked questions

What is AshbyHQ?

Ashby (ashbyhq.com) is an all-in-one recruiting platform: an applicant tracking system with CRM and sourcing, scheduling and analytics.

Does Ashby have an open API?

Yes, a documented public API, but every call needs an admin-created API key. Only the Job Postings API reads published jobs without one.

Can I get a free Ashby API key?

Keys are created by an admin inside an Ashby account, and Ashby's help center lists them on the Foundations, Legacy Plus, Plus and Enterprise plans.

How do I download a candidate's resume with the Ashby API?

Send resumeFileHandle.handle from candidate.info to file.info as fileHandle, then download the url it returns.

Sources and further reading

  1. Ashby API reference: Introduction
  2. Ashby API reference: Authentication and permissions
  3. Ashby API reference: Versioning
  4. Ashby API reference: Responses & Errors
  5. Ashby changelog: X-RateLimit-Scope header on rate limit responses
  6. Ashby guide: Pagination and Incremental Synchronization
  7. Ashby API reference: candidate.list
  8. Ashby API reference: candidate.info
  9. Ashby API reference: application.list
  10. Ashby API reference: file.info
  11. Ashby API reference: candidate.createNote
  12. Ashby API reference: customField.setValue
  13. Ashby guide: Setting up Webhooks
  14. Ashby guide: Authenticating Webhooks
  15. Ashby guide: Webhook retries
  16. Ashby API reference: applicationSubmit webhook
  17. Ashby API reference: webhook.create
  18. Ashby guide: Job Postings API
  19. Ashby help center: How do I generate an API key?
  20. Ashby: all-in-one recruiting platform
  21. HireLayer API documentation: Parse a resume (V3)
  22. HireLayer API documentation: Errors and retries

Louis Desclous

Published on · Reading time: 5 minutes