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 facts | Harvest v3 |
|---|---|
| Base URL | https://harvest.greenhouse.io/v3 |
| Auth | OAuth 2.0 client credentials at
https://auth.greenhouse.io/token, then a Bearer token |
| Format | JSON; list endpoints return an array |
| Pagination | Cursor in the Link header (
rel="next"
), per_page 100 by default, 500 max |
| Rate limits | Fixed 30-second window, read from X-RateLimit-* headers;
429 with Retry-After |
| Webhooks | HTTPS POSTs signed with HMAC SHA-256, up to 7 attempts over 15 hours |
| Resume files | GET /v3/attachments?type=resume; URLs expire after 7 days |
| Docs | harvestdocs.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
- In Greenhouse, open API Credentials, create
Harvest V3 (OAuth) credentials and pick scopes such as
harvest:attachments:list(the pattern isharvest:resource:action). You get a client ID and a secret. - POST to
https://auth.greenhouse.io/tokenwith Basic auth andgrant_type=client_credentials. - Send the
access_tokenas a Bearer token, and fetch a new one on401or whenexpires_inruns 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 optionalapplication_idechoed back ininfo_resume.application_id. - Files: PDF, Word, images and more, up to about 4.5 MB, in 70 languages, scans included.
- Timing: about 35 seconds,
504after 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 200Frequently 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
- Greenhouse Harvest v3: Authentication
- Greenhouse Harvest v3: Rate Limiting
- Greenhouse Harvest v3: Pagination
- Greenhouse Harvest v3: List endpoints
- Greenhouse Harvest v3 reference: List attachments
- Greenhouse Harvest v3 reference: List applications
- Greenhouse Harvest v3: READ endpoint migration guide
- Greenhouse Harvest API v1/v2 (deprecation notice)
- Greenhouse Recruiting Webhooks
- Greenhouse Job Board API
- Greenhouse Candidate Ingestion API
- Greenhouse Assessment API
- HireLayer API documentation: Parse a resume (V3)
- HireLayer API documentation: Errors and retries
Louis Desclous
Published on · Reading time: 5 minutes
