Skip to content

Greenhouse API Guide: Harvest v3, Auth, Resumes and Rate Limits

Which Greenhouse API to use, OAuth for Harvest v3 after the v1/v2 deprecation, pagination, rate limits and resume attachments, then parsing resumes into JSON.

Published · 5 minutes read

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

The Greenhouse API most developers need is Harvest v3, the REST API at https://harvest.greenhouse.io/v3 that reads and writes your candidates, applications, jobs, attachments and notes. You authenticate with OAuth 2.0 client credentials and send the token as Authorization: Bearer. Greenhouse has deprecated Harvest v1 and v2, and its official banner still gives August 31, 2026 as their removal date, so build new code on v3.

Key factsHarvest v3
Base URLhttps://harvest.greenhouse.io/v3
AuthOAuth 2.0 client credentials at https://auth.greenhouse.io/token, then a Bearer token
FormatJSON; list endpoints return an array
PaginationCursor in the Link header ( rel="next" ), per_page 100 by default, 500 max
Rate limitsFixed 30-second window, read from X-RateLimit-* headers; 429 with Retry-After
WebhooksHTTPS POSTs signed with HMAC SHA-256, up to 7 attempts over 15 hours
Resume filesGET /v3/attachments?type=resume; URLs expire after 7 days
Docsharvestdocs.greenhouse.io and docs.greenhouse.io, checked November 9, 2026

Which Greenhouse API do you need?

To pull candidates and resumes out of your own account, use Harvest v3, plus web hooks if you want to react the moment someone applies. The other APIs serve different callers:

  • Job Board API: published jobs as JSON for your careers page. GET needs no auth; submitting an application uses Basic auth with the Job Board API key.
  • Recruiting web hooks: JSON POSTs to your HTTPS endpoint when events happen.
  • Candidate Ingestion and Assessment APIs: for sourcing and testing partners, not for reading your own data.

Moving a v1 script? Three changes bite: OAuth replaces Basic auth, the attachments and applications on a candidate are deprecated in favor of their own endpoints, and page numbers give way to a cursor. Greenhouse's migration guide maps each v1 endpoint to its v3 equivalent, wich beats a search-and-replace.

Step 1: Create OAuth credentials and get a token

  1. In Greenhouse, open API Credentials, create Harvest V3 (OAuth) credentials and pick scopes such as harvest:attachments:list (the pattern is harvest:resource:action). You get a client ID and a secret.
  2. POST to https://auth.greenhouse.io/token with Basic auth and grant_type=client_credentials.
  3. Send the access_token as a Bearer token, and fetch a new one on 401 or when expires_in runs out.

The whole exchange in two curl calls:

curl --location 'https://auth.greenhouse.io/token' \
  --user "$GH_CLIENT_ID:$GH_CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'grant_type=client_credentials'

# {"token_type": "Bearer", "access_token": "eyJ...", "expires_in": 3600}

curl --location 'https://harvest.greenhouse.io/v3/applications?per_page=2' \
  --header "Authorization: Bearer $ACCESS_TOKEN"

List endpoints require Site Admin authorization. Without the optional sub parameter, the token acts as the integration's service user with organization-wide access; set sub to a user and you only see what they can see. So an empty array is usualy a permissions issue, not a bug.

Step 2: List new resumes

Resumes live in GET /v3/attachments: filter with type=resume and a created_at range such as gte|2026-10-26T00:00:00Z (don't combine it with updated_at). For the pipeline around them, GET /v3/applications filters by job_ids, candidate_ids and status.

Every response also carries X-RateLimit-Remaining and X-RateLimit-Reset (a UTC epoch), so a busy sync can slow down before it hits the limit. Token requests have their own 60-second window, so reuse one token for the whole run instead of fetching one per call.

Honestly, the one rule to remember: after the first call, request the next URL exactly as given, because any other parameter next to cursor returns 422. This Python 3.10+ client handles that and the 429 backoff:

import os
import time
from datetime import datetime, timedelta, timezone
import requests

GH_API = "https://harvest.greenhouse.io/v3"
session = requests.Session()


def greenhouse_token() -> str:
    """OAuth client credentials: Basic auth with the client ID and secret."""
    response = session.post(
        "https://auth.greenhouse.io/token",
        auth=(os.environ["GH_CLIENT_ID"], os.environ["GH_CLIENT_SECRET"]),
        data={"grant_type": "client_credentials"},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["access_token"]  # valid for expires_in seconds


def list_all(path: str, params: dict):
    """Yield every record, waiting on 429 and following the Link header."""
    headers = {"Authorization": f"Bearer {greenhouse_token()}"}
    url = f"{GH_API}/{path}"
    while url:
        response = session.get(url, params=params, headers=headers, timeout=30)
        if response.status_code == 429:
            time.sleep(float(response.headers.get("Retry-After", "30")))
            continue
        response.raise_for_status()
        yield from response.json()  # list endpoints return a JSON array
        # The next URL carries an opaque cursor: call it as is, no other params.
        url, params = response.links.get("next", {}).get("url"), None

since = datetime.now(timezone.utc) - timedelta(days=1)
resumes = list_all(
    "attachments",
    {"type": "resume", "created_at": f"gte|{since:%Y-%m-%dT%H:%M:%SZ}", "per_page": 500},
)

Step 3: Download and parse each resume

Download each file right away, since the attachment URL expires after seven days. Then send it to HireLayer CV Extract: one multipart POST /api/v3/parser with an X-API-Key header returns the candidate as JSON (see the CV Extract reference).

  • Body: file, plus an optional application_id echoed back in info_resume.application_id.
  • Files: PDF, Word, images and more, up to about 4.5 MB, in 70 languages, scans included.
  • Timing: about 35 seconds, 504 after 145 seconds, so use a 150-second client timeout.
  • Cost: 1 credit per successful parse; the Free plan includes 50 credits a month.

Passing the Greenhouse application ID as application_id keeps the two systems joined. A 422 means the file isn't a resume, a 403 that you're out of credits. There's no idempotency key, so don't shorten the timeout: a request your client abandons can still complete and be charged.

In practice, you retry 5xx with backoff and leave 4xx alone, as the errors guide says. This reuses resumes and the imports from step 2:

HIRELAYER_URL = "https://hirelayer.co/api/v3/parser"


def parse_resume(content: bytes, filename: str, application_id: int) -> dict | None:
    """Send one resume to HireLayer CV Extract. 1 credit per HTTP 200."""
    for attempt in range(4):
        response = requests.post(
            HIRELAYER_URL,
            headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
            files={"file": (filename, content)},
            data={"application_id": f"greenhouse-{application_id}"},
            timeout=150,  # about 35 s per resume, 504 after 145 s
        )
        if response.status_code >= 500 and attempt < 3:
            time.sleep(float(response.headers.get("Retry-After", 2**attempt)))
            continue
        if response.status_code != 200:  # 4xx is never retried unchanged
            print(application_id, response.status_code, response.text[:200])
            return None
        return response.json()
    return None


for attachment in resumes:
    # The url expires after seven days: download now (redirects are followed).
    file = requests.get(attachment["url"], timeout=60)
    file.raise_for_status()
    parsed = parse_resume(file.content, attachment["filename"], attachment["application_id"])
    if parsed:
        candidate = parsed["info_candidate"]
        skills = [s["skill_title"] for s in parsed["skills"][:5]]
        print(attachment["application_id"], candidate["job_title"], skills)

A trimmed response:

{
  "status": "success",
  "info_resume": { "application_id": "greenhouse-71980812", "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": "React", "skill_type": "Software skill" }]
}

To write the ouput back into Greenhouse as a note, tags or employment history, see the Greenhouse integration page. The same call in Node.js is in the resume PDF to JSON tutorial.

Step 4: Keep in sync with web hooks

Greenhouse pings your HTTPS URL when you save the hook and creates it disabled if the ping fails. Polling works, but an Application created web hook sends new_candidate_application with the candidate's attachments as soon as someone applies. Check the Signature header (sha256 <hex>, an HMAC of the raw body with your secret) on the exact bytes you received, not on re-serialized JSON.

Greenhouse retries a timeout or a non-200 answer, up to 7 attempts over 15 hours, so it isn't worth parsing inside the request. Return 200 fast and queue the work:

import hashlib
import hmac
import json
import os


def is_from_greenhouse(raw_body: bytes, signature_header: str) -> bool:
    """The Signature header reads "sha256 <hex digest>", computed on the raw body."""
    expected = hmac.new(
        os.environ["GH_WEBHOOK_SECRET"].encode(), raw_body, hashlib.sha256
    ).hexdigest()
    _, _, received = signature_header.partition(" ")
    return hmac.compare_digest(received, expected)


def handle(raw_body: bytes, headers: dict) -> int:
    if not is_from_greenhouse(raw_body, headers.get("Signature", "")):
        return 401
    event = json.loads(raw_body)
    if event.get("action") != "new_candidate_application":
        return 200
    application = event["payload"]["application"]
    for attachment in application["candidate"]["attachments"]:
        if attachment["type"] == "resume":
            # Answer fast: your own queue runs the download and parse later.
            enqueue(application["id"], attachment["url"], attachment["filename"])
    return 200

Frequently asked questions

Does Greenhouse have a public API?

Yes. Harvest v3 reads and writes your account's data, the Job Board API exposes published jobs, and recruiting web hooks push events; the docs are public at harvestdocs.greenhouse.io and docs.greenhouse.io.

Is the Greenhouse Harvest API v1 deprecated?

Yes. Greenhouse's v1/v2 reference marks the API as deprecated, still gives August 31, 2026 as the removal date and points developers to Harvest v3.

How do I get a Greenhouse API key?

Harvest v3 uses OAuth credentials instead of an API key: create Harvest V3 (OAuth) credentials under API Credentials, then exchange the client ID and secret for a token at auth.greenhouse.io/token.

What is the Greenhouse API rate limit?

Harvest v3 counts requests in a fixed 30-second window and returns your limit in the X-RateLimit headers of every response. On a 429, wait the number of seconds in Retry-After.

Try a Greenhouse resume in the live demo

Upload a resume downloaded from Greenhouse to see the JSON CV Extract returns, then get 50 free credits a month to run the script.

Sources and further reading

  1. Greenhouse Harvest v3: Authentication
  2. Greenhouse Harvest v3: Rate Limiting
  3. Greenhouse Harvest v3: Pagination
  4. Greenhouse Harvest v3: List endpoints
  5. Greenhouse Harvest v3 reference: List attachments
  6. Greenhouse Harvest v3 reference: List applications
  7. Greenhouse Harvest v3: READ endpoint migration guide
  8. Greenhouse Harvest API v1/v2 (deprecation notice)
  9. Greenhouse Recruiting Webhooks
  10. Greenhouse Job Board API
  11. Greenhouse Candidate Ingestion API
  12. Greenhouse Assessment API
  13. HireLayer API documentation: Parse a resume (V3)
  14. HireLayer API documentation: Errors and retries

Louis Desclous

Published on · Reading time: 5 minutes