If you have an ATS integration that reads Greenhouse data — a custom recruiter dashboard, a Snowflake warehouse pulling candidate history, a Slack bot that posts interview scorecards, anything piped through the Harvest API — you have nine weeks. On August 31, 2026, Greenhouse removes Harvest API v1 and v2 entirely. Every integration must be on v3 by that date.
The Greenhouse Harvest landing page puts it plainly: "The Harvest v1/v2 API is deprecated and will be removed on August 31, 2026. Please migrate to Harvest v3."
The loud failure mode after the cutoff is the one teams plan around. v1/v2 endpoints stop accepting requests, you get a 4xx, you redeploy with v3.
The failure modes worth thinking about live in the migration window — the next nine weeks, while v1/v2 and v3 both work and integrations are partly converted. Three of them swallow data without raising an error.
The v3 changes that matter for silent drift
Before the failure modes, the v3 shape. Three things changed that touch every paginated endpoint:
| Surface | v1 / v2 | v3 |
|---|---|---|
| Auth | Basic Auth (API token as username, blank password, Base64-encoded) | OAuth 2.0 bearer token |
| Pagination |
Link header (RFC 5988) with rel="next", rel="prev", rel="last"
|
Link header with only rel="next", cursor must be the only query parameter on subsequent requests |
| Rate limiting |
X-RateLimit-Limit over a 10-second window |
The same headers over a longer fixed window |
The pagination row is the one the v3 docs are most emphatic about. From the Harvest v3 pagination guide: "When you pass a cursor, it must be the only query parameter." And: "Treat the cursor as an opaque value: don't parse it, and don't try to construct it yourself."
Both of those are sharp. Both of them break in ways the migration team is going to miss.
1. The "cursor + filter → 422 → end of pages" silent truncation
The most common shape of a v1/v2 paginated extract:
url = f"/v1/applications?job_id={job_id}&per_page=500"
while url:
resp = session.get(url)
resp.raise_for_status()
rows.extend(resp.json())
url = parse_link_header(resp.headers.get("Link", "")).get("next")
Note the structure of the loop: the first request carries the filters (job_id, per_page, created_after, whatever). The subsequent requests follow the Link: <...>; rel="next" URL the server hands back. In v1/v2, that URL has all the filter parameters baked in. The loop stops when there is no next link.
A migration that converts the first request to v3 but forgets that v3's cursor rule is "cursor must be the only query parameter" produces this:
url = "/v3/applications?job_id=123&per_page=500" # first request
while url:
resp = session.get(url)
resp.raise_for_status() # <-- the problem
rows.extend(resp.json())
url = parse_link_header(resp.headers.get("Link", "")).get("next")
The first request succeeds and returns a page of applications for job_id=123 plus a Link header with a cursor URL. Greenhouse's cursor URL does not include job_id=123 — the cursor encodes the position internally. The migration team, looking at this from a code-review distance, doesn't notice that job_id has dropped.
The next iteration follows the cursor URL. v3 accepts it. The pages keep coming. The pipeline ingests applications from job_id=123, then applications from job_id=124, then 125, and so on — every application in the tenant, not just the one job the original query asked for.
That's the version where the loop doesn't even raise. The version that does raise looks like this — the migration team appends the cursor to a URL builder that still adds tenant-default query parameters:
url = build_url("/v3/applications", cursor=cursor_from_link, per_page=500)
Greenhouse v3 returns 422 Unprocessable Content because there are two query parameters and cursor must be alone. A pipeline whose retry logic treats 4xx as "permanent — stop paginating" silently completes with whatever rows it got from page 1.
There's no error in your warehouse. Your row counts are lower than yesterday's, but day-over-day variance on ATS data is normal and nobody alerts on a 30% drop on a Tuesday. The integration's healthcheck shows green because the last call returned a response. The deferred symptom is "our recruiting funnel dashboard has been wrong for two weeks."
2. The prev and last Link rels are gone
v3 returns only rel="next". The official guide: "Harvest v3 currently returns only a next link (no prev or last)."
For most batch ETL, this doesn't matter — you walked forward, you stopped at the end. For interactive consumers, it matters a lot.
The patterns that break silently:
-
"Jump to last page" controls in admin UIs reading the
rel="last"link and using its cursor. TheLinkheader lacks that rel; a permissive HTTP client returnsNonerather than raising; the UI button stops working. Most ATS admin tools route through a generic API gateway that swallows missing rels. -
"Page back" controls that walked
rel="prev". Same story — silently dead, no error. -
Total-count estimates built by following
rel="last"and reading the page number from the URL. The page number isn't in the URL anyway (v3 uses opaque cursors), so this was already going to break, but now it breaks before the URL parser gets a chance to fail — the rel just isn't there.
If your codebase has any of links["prev"], links["last"], or a parse_link_header call that doesn't assert which rels it got, those callsites need an audit before September 1.
3. Cursor parsing for "where am I" silently desyncs
The v3 cursor is opaque. The docs are direct: "don't parse it, and don't try to construct it yourself." In practice it's a Base64-encoded value that encodes a primary-key position — Harvest paginates by id descending.
Two patterns that break:
The "resume from yesterday" cron. Long-running ETL pipelines often persist the last cursor they consumed and resume from that cursor the next day. v1/v2 had stable, predictable next-URLs that were safe to store. v3's cursor is opaque, and Greenhouse hasn't published a stability guarantee — a cursor stored today may produce 422 Unprocessable Content tomorrow if the cursor format changes, or it may silently produce data from a different position if the encoding's interpretation drifts. The safer pattern is to store a high-water mark by id or updated_at and re-paginate from there each run, not to persist cursors.
The "skip to row 10,000" debug helper. Engineers who built tools that take a cursor as a CLI flag and parse it to print "you're at position X" silently degrade to "you're at position [unparseable]." Worse, helpers that construct a synthetic cursor (e.g., to start partway through a sync) hit a 422 and either crash or get silently treated as "no more data."
Anyone writing v3-compatible code should treat the cursor exactly like the docs say — a token to be passed back, never inspected. Code that diverges from this rule is the first thing to grep for during the migration audit.
What to grep for before September 1
Four targeted searches that catch the bulk of v1/v2-shaped code in a typical Harvest integration:
# Direct version pins
grep -rE 'harvest\.greenhouse\.io/v[12]|api/v[12]/(candidates|applications|jobs|offers)' src/
# Basic Auth that won't carry to v3
grep -rE 'Authorization: Basic|base64.*api[_-]?key.*:' src/ scripts/
# Link header parsers that read rels v3 doesn't return
grep -rE 'rel=["\x27]?(prev|last)|links\[["\x27](prev|last)' src/
# Cursor parsing or construction
grep -rE 'base64.*decode.*cursor|cursor\s*=\s*[fF]"|f["\x27]\?cursor=' src/
The non-grep audit is the persisted-config check: any Airbyte connector, Fivetran connector, Workato recipe, or n8n workflow with harvest.greenhouse.io/v1 or /v2 in its config string needs to be re-pointed before August 31, and any stored cursors flushed before the version flip.
Why this lands silently when v3 is more strict, not less
The strict-pagination rule (cursor must be alone) is exactly the kind of contract change that produces silent failure when error handling is generic. A 200-OK with the wrong rows fails open. A 422 from a pipeline that treats 4xx as "stop, no more data" also fails open. The only loud failure is "the v1 endpoint is gone" — and that's the one that doesn't fire until September 1.
The window from now to August 31 is when the wrong code is most likely to ship: v3 endpoints work, v1/v2 endpoints work, and the migration looks "done" because all the tests pass and the dashboards have data. The data is just from the wrong rows.
FlareCanary watches the response shapes of API endpoints you depend on and tells you when filter parameters silently drop, pagination links vanish from headers, or row counts shift in ways that look normal but aren't. Greenhouse's v3 migration is exactly the kind of transition where a passing healthcheck and a clean Sentry feed don't mean what you think they mean. flarecanary.com
Top comments (0)