Adzuna: what we actually get from it (WING-333 research)¶
Date: 2026-08-05 · Branch: wt/WT-a225/ats-resolution-ladder · Status: measured, not inferred
The question asked: does Adzuna reach employers/ATSes we do not already reach through our direct board sources (Greenhouse/Lever/Ashby), and how many distinct ATSes does it give us?
Short answer: zero, today. Not "a few" — zero listings resolve to an employer ATS, because Adzuna's listing links cannot be resolved by any non-interactive client.
The defect that started this¶
AdzunaSource hardcoded Kind = AtsKind.Unknown and filled TenantHost with the host of Adzuna's
own tracker URL. Because EngineRegistry.Resolve(AtsKind) routes by kind, Resolve(Unknown)
returns null — so automation could never apply to any Adzuna row. Every Adzuna job in the queue
was silently manual-only. A populated-but-false TenantHost made it worse: it reads as verified.
Fixed on this branch (tagging + TenantHost left empty until verified). The resolution half is
what this research is about.
Measurements¶
All numbers below are observed, each from a run recorded in this ticket's session.
| Client | Sample | Result |
|---|---|---|
Bare HttpClient (production resolver) |
180 listings, 6 occupations | 403 on 180/180 — 0% resolved |
curl with browser User-Agent |
1 | 403 — UA is not the discriminator |
curl on adzuna.com/robots.txt |
1 | 403 — we cannot even read their crawl policy |
| Headless Chromium (Playwright) | 40 | 403 on 36/40 |
| Headful Chromium (Playwright) | 15 | 0 blocked — WAF cleared |
| Headful + follow apply link | 14 | 14/14 dead end — no apply control in a fresh context |
| Real browser, ordinary profile | 1 | reached the employer hand-off → jobcase.com |
The 403 body identifies the blocker: Generated by cloudfront (CloudFront) — a WAF at the edge.
What the block is actually keyed on¶
Not IP alone (bare HTTP from a residential IP is blocked). Not User-Agent (a browser UA is blocked). Headless-ness is the discriminator: identical machine, identical residential IP, minutes apart — headless 403s, headful passes. That is a control aimed specifically at automated browsers.
Beyond the WAF there is a second gate: a fresh automated browser context (no cookies/consent) loads the page but is not served the apply control, while an ordinary browser profile is. So clearing the WAF is necessary but not sufficient.
The chain shape¶
/land/ad/{id} → 302 → /details/{id} (Adzuna page, ZERO external links)
click "Apply for this job" → /land/ad/{id}?aztt=<signed JWT>&from_adp=1
→ external destination → jobcase.com (ANOTHER aggregator, not an ATS)
Two findings here:
- The employer URL is only reachable by clicking Apply. It is not in the page, not in a
redirect header, and the apply link carries a freshly signed, expiring token (
aztt, a JWT). No amount of redirect-chain following reaches it — this is why the production resolver, which follows 3xx and never reads a body, returns Unknown for 100% of Adzuna rows. - The destination is often another aggregator. Two independent end-to-end traversals, two aggregator destinations, zero employer ATSes:
- automated (this research) → jobcase.com
- manual, by Andrew in his own browser → ZipRecruiter
Adzuna syndicates substantially from other job boards, so a large share of its corpus is 2+ hops from any employer ATS — and the next hop is frequently a competitor's board rather than a Workday/Greenhouse/iCIMS tenant we could actually drive. This is the finding that decides the question. Even a perfect resolver would often conclude "this job lives on ZipRecruiter", which is not ATS coverage.
Who Jobcase is¶
Independent company, founded 2009 (Fred Goff), positioned as "LinkedIn for the hourly workforce", ~6.7M visitors Dec 2025. Not owned by Adzuna — no ownership link found. The two co-appear in partner ecosystems. (Adzuna's acquisition in this space is Joblift, Germany — easy to confuse.)
The strategic fact¶
Adzuna ships ApplyIQ, their own AI application product. They are a competitor in the same category as Work Wingman, not a neutral supplier. That gives a commercial motive for blocking automated traversal of their apply funnel, and it means any circumvention we build is an arms race against a competitor actively defending their conversion path.
Options considered¶
| Option | Verdict |
|---|---|
| Body-reading hop (meta-refresh / JS interstitial) | Dead. There is no body — 403 before content. |
| Headless browser | Dead. Specifically blocked. |
| Headful browser (GKE + Xvfb, or bundled hidden desktop browser) | Technically clears the WAF. Also hits the fresh-context gate. Is deliberate evasion of an anti-automation control belonging to a competitor. |
| Residential / sticky proxy egress | Same evasion question, plus correlated-ban blast radius: one block takes out every cloud user at once. |
| Tavily (third-party fetch) | Same circumvention, outsourced. Also discloses every job URL to a third party. |
| Discovery-only | Row is visible, user opens it themselves, applies manually. Honest, available today, no automation. |
| Ask Adzuna | We are a registered API consumer whose redirect_url their own edge refuses. Cheapest path to a clean answer. |
CORRECTION: the chain does reach a drivable ATS¶
An earlier draft of this document concluded that Adzuna "does not hold employer ATS links" and that there was "no ATS coverage behind the block". Both statements were wrong, disproved by a manual traversal Andrew ran in his own browser. Recorded here rather than quietly edited out, because the error was in the direction of giving up on a source that turns out to be valuable.
Full chain, followed end to end:
Adzuna /land/ad/{id}
→ ZipRecruiter
→ jobs.spectrum.com (employer VANITY domain — no vendor marker in the host)
→ careers-charter.icims.com (iCIMS — a real, drivable tenant)
Verified at each step:
AtsDetector.DetectFromUrl("https://jobs.spectrum.com/job/...")→ Unknown. A vanity career domain hides the vendor, so URL-only detection cannot name it. This is not a detector defect; the information is genuinely not in the URL.- Fetching that page with a plain HTTP client → 200, 187 KB. Employer sites are not WAF'd the way Adzuna is.
- The page HTML contains
https://careers-charter.icims.com, andAtsDetector.cs:18matcheshtml.Contains("icims.com")→AtsKind.Icims. The existing HTML detector already identifies it correctly, including the real tenant host. We ship an iCIMS apply engine.
What this changes¶
| Hop | Status |
|---|---|
1. Adzuna /land/ad/ |
403 to any automated client — the only real blocker |
| 2. Intermediate aggregator (ZipRecruiter / Jobcase) | fetchable |
| 3. Employer vanity career site | fetchable with plain HTTP |
| 4. Name the ATS from page HTML | already works today |
Everything downstream of hop 1 works with code already in the repo. The earlier claim that the body-reading rung was "dead" was also wrong: it is dead against Adzuna's 403, but it is required for step 4, because vanity domains are exactly the case URL matching cannot solve.
Recommendation¶
The value is real — Adzuna listings do lead to drivable ATSes with genuine tenant hosts. So this is not a cost/benefit question any more. It reduces to a single access decision:
Do we clear Adzuna's anti-automation control at hop 1?
That is a business/ToS judgement, not a technical one. What is known:
- The control specifically targets automation: headless is blocked, headful passes, from the same IP minutes apart. They are not incidentally catching datacenters.
- Adzuna ships ApplyIQ, a competing AI application product, so they have a commercial motive to defend this funnel.
- Adzuna is an established, partnership-oriented company (they have run the UK government's official "Find a Job" portal), which makes asking them a credible first move rather than a formality.
- We are a registered API consumer, and their own API hands us a
redirect_urltheir edge then refuses. That is plausibly a misconfiguration worth reporting regardless of the outcome.
Recommended sequence: ask Adzuna first; ship discovery-only in the meantime (row visible, user opens it themselves, no claimed ATS); revisit automated resolution only on their answer.
Adzuna's value even without hop-1 access is search breadth — surfacing that a job exists at all, which the user can then act on manually.
It makes dedupe harder, not easier¶
Because Adzuna resells from other boards, one req can arrive five ways — Adzuna, ZipRecruiter, Jobcase, a LinkedIn saved job, and a Greenhouse API row — with five different URLs and five differently-rewritten company/title strings. Only the Greenhouse row carries a stable id. Every aggregator row added to the queue is a row that cannot be reliably deduped.
Consequences for other work¶
- Dedupe (open problem). The same req can arrive as a Greenhouse API row, a LinkedIn saved job,
an Indeed saved job, an Adzuna row, and an Adzuna→Jobcase row — five entries, five URLs, one job.
ListingIdentity(SHA256 company+title+locationBucket, exact-only auto-link, never a silent fuzzy merge) is the right primitive but cannot fire reliably on aggregator rows, because each hop rewrites the company/title strings and the ATS stays Unknown. The dependable key is the ATS-native vendor posting id — the free "confirmation rung" the design council named as the single biggest gap. - Faceted search (WING-315). Adzuna's
UnknownATS bucket never drains. Any facet that filters to a known ATS silently drops every Adzuna row. (Same hazard as USAJOBS, below.) - USAJOBS. Separate but related: its
ApplyURIpoints back at usajobs.gov, and the hand-off to USA Staffing / Application Manager happens after login, behind Apply. It is terminal, not unresolved, andAtsKindhas no federal values, so federal rows can only beOther.
Reproducing¶
POC lives in the session scratchpad (adzpoc/), C#, references the production AtsDetector so its
classifications mean the same thing they will mean in the app. Credentials come from GCP Secret
Manager (workwingman-adzuna-credentials) — note that ApiKeyProvider deliberately refuses
config/env keys in a sandbox world (WING-318 fail-closed), so the POC queries Adzuna's API directly
rather than through the app.