santifer/career-ops · error · Error
greenhouse: cannot derive API URL for ${entry.name}
Error message
greenhouse: cannot derive API URL for ${entry.name} What it means
Thrown by greenhouse fetch() when resolveApiUrl(entry) returns null — the provider could not build a Greenhouse boards-api endpoint from the portal entry. resolveApiUrl succeeds only when entry.api is a URL on the Greenhouse host allowlist, OR entry.careers_url matches the regex job-boards(.eu)?.greenhouse.io/{slug}. Any other careers_url host (including the older boards.greenhouse.io) yields null. The throw is intentional: a misconfigured board returning zero jobs would otherwise be indistinguishable from a genuinely empty board.
Source
Thrown at providers/greenhouse.mjs:132
return map;
}
/** @type {Provider} */
export default {
id: 'greenhouse',
detect(entry) {
try {
const apiUrl = resolveApiUrl(entry);
return apiUrl ? { url: apiUrl } : null;
} catch {
return null;
}
},
async fetch(entry, ctx) {
const apiUrl = resolveApiUrl(entry);
if (!apiUrl) throw new Error(`greenhouse: cannot derive API URL for ${entry.name}`);
assertGreenhouseUrl(apiUrl);
// redirect:'error' prevents SSRF via server-side redirects; combined with
// assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist.
const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' }));
const jobs = Array.isArray(json?.jobs) ? json.jobs : [];
const usable = jobs.filter(/** @param {any} j */ j => j.absolute_url);
// Only pay for /offices when this board actually hides its cities there.
let officeMap = null;
if (usable.some(/** @param {any} j */ j => isWorkModelOnly(j.location?.name))) {
const officesUrl = officesUrlFor(apiUrl);
if (officesUrl) {
try {
assertGreenhouseUrl(officesUrl);
officeMap = buildOfficeMap(await ctx.fetchJson(officesUrl, { redirect: 'error' }));
} catch (err) {
// No /offices on this board, or it failed — fall back to the bare
// work-model string. Enrichment is best-effort; a scan must neverView on GitHub (pinned to 9b17a8ac97)
Solutions
- Set api: explicitly to the boards-api URL, e.g. api: https://boards-api.greenhouse.io/v1/boards/{slug}/jobs — this bypasses careers_url parsing entirely.
- If you only have the public careers URL, set careers_url to the job-boards.greenhouse.io/{slug} form (the host the derive regex actually matches).
- Confirm the slug by opening https://boards-api.greenhouse.io/v1/boards/{slug}/jobs in a browser; a {"jobs":[...]} payload means it is correct.
- Verify with detect(): run provider.detect(entry) — if it returns null, the entry will fail fetch() too.
Example fix
# before - name: Acme provider: greenhouse careers_url: https://www.acme.com/careers # after - name: Acme provider: greenhouse api: https://boards-api.greenhouse.io/v1/boards/acme/jobs
Defensive patterns
Strategy: validation
Validate before calling
// Run detect() before fetch() — it returns null for exactly the entries
// that would throw 'cannot derive API URL'. Route only on a non-null hit.
const hit = greenhouseProvider.detect(entry);
if (!hit) {
console.warn(`greenhouse: skipping ${entry.name} — no derivable API URL (set api: or a job-boards.greenhouse.io careers_url)`);
continue;
}
const jobs = await greenhouseProvider.fetch(entry, ctx); Type guard
/** True when the entry can yield a Greenhouse boards-api URL. */
function isGreenhouseDerivable(entry) {
if (!entry || typeof entry !== 'object') return false;
if (typeof entry.api === 'string') {
try { const u = new URL(entry.api); return u.protocol === 'https:'; } catch { return false; }
}
return typeof entry.careers_url === 'string'
&& /job-boards(?:\.eu)?\.greenhouse\.io\/[^/?#]+/.test(entry.careers_url);
} Try / catch
try {
const jobs = await greenhouseProvider.fetch(entry, ctx);
} catch (err) {
if (/cannot derive API URL/.test(err.message)) {
// Config error, not transient — do NOT retry; log and flag the entry.
console.error(`config: ${entry.name} — ${err.message}`);
} else {
throw err; // network / shape errors should propagate
}
} Prevention
- Always pair provider: greenhouse with either an explicit api: boards-api URL or a job-boards.greenhouse.io careers_url.
- Remember the derive regex only matches job-boards.greenhouse.io / job-boards.eu.greenhouse.io — not the older boards.greenhouse.io host.
- Gate every entry through detect() in a pre-scan validation pass so misconfigured boards are reported, not silently empty.
When it happens
Trigger: A portal entry routed to the greenhouse provider whose careers_url is on boards.greenhouse.io (legacy host, not in the derive regex), on the company's own branded domain, or absent entirely — and no api: field supplied. resolveApiUrl only matches job-boards.greenhouse.io / job-boards.eu.greenhouse.io, so every other host returns null. Also fires when entry.api is set but fails assertGreenhouseUrl (wrong host / non-https), since resolveApiUrl re-throws that internally and detect() swallows it to null, leaving fetch() to hit the null branch.
Common situations: Pasting a careers link copied from a company's own site that redirects to Greenhouse; a Greenhouse tenant that migrated to a vanity subdomain; accidentally tagging an Ashby or Lever URL with provider: greenhouse; YAML indentation putting careers_url under the wrong key so the entry reads undefined.
Related errors
- breezy: cannot derive API URL for ${entry.name}
- comeet: cannot derive API URL for ${entry.name} (set api: to
- consider: ${entry.name} needs an https careers_url on a publ
- csod: cannot resolve careersite URL for ${entry.name}
- deutschebahn: cannot resolve db.jobs search id for ${entry.n
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/02d8e34e89d732dc.
Report an issue: GitHub.