{"record":{"id":"02d8e34e89d732dc","repo":"santifer/career-ops","slug":"greenhouse-cannot-derive-api-url-for-entry-name","errorCode":null,"errorMessage":"greenhouse: cannot derive API URL for ${entry.name}","messagePattern":"greenhouse: cannot derive API URL for (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/greenhouse.mjs","lineNumber":132,"sourceCode":"  return map;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'greenhouse',\n\n  detect(entry) {\n    try {\n      const apiUrl = resolveApiUrl(entry);\n      return apiUrl ? { url: apiUrl } : null;\n    } catch {\n      return null;\n    }\n  },\n\n  async fetch(entry, ctx) {\n    const apiUrl = resolveApiUrl(entry);\n    if (!apiUrl) throw new Error(`greenhouse: cannot derive API URL for ${entry.name}`);\n    assertGreenhouseUrl(apiUrl);\n    // redirect:'error' prevents SSRF via server-side redirects; combined with\n    // assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist.\n    const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' }));\n    const jobs = Array.isArray(json?.jobs) ? json.jobs : [];\n    const usable = jobs.filter(/** @param {any} j */ j => j.absolute_url);\n\n    // Only pay for /offices when this board actually hides its cities there.\n    let officeMap = null;\n    if (usable.some(/** @param {any} j */ j => isWorkModelOnly(j.location?.name))) {\n      const officesUrl = officesUrlFor(apiUrl);\n      if (officesUrl) {\n        try {\n          assertGreenhouseUrl(officesUrl);\n          officeMap = buildOfficeMap(await ctx.fetchJson(officesUrl, { redirect: 'error' }));\n        } catch (err) {\n          // No /offices on this board, or it failed — fall back to the bare\n          // work-model string. Enrichment is best-effort; a scan must never","sourceCodeStart":114,"sourceCodeEnd":150,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/providers/greenhouse.mjs#L114-L150","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before\n- name: Acme\n  provider: greenhouse\n  careers_url: https://www.acme.com/careers\n\n# after\n- name: Acme\n  provider: greenhouse\n  api: https://boards-api.greenhouse.io/v1/boards/acme/jobs","handlingStrategy":"validation","validationCode":"// Run detect() before fetch() — it returns null for exactly the entries\n// that would throw 'cannot derive API URL'. Route only on a non-null hit.\nconst hit = greenhouseProvider.detect(entry);\nif (!hit) {\n  console.warn(`greenhouse: skipping ${entry.name} — no derivable API URL (set api: or a job-boards.greenhouse.io careers_url)`);\n  continue;\n}\nconst jobs = await greenhouseProvider.fetch(entry, ctx);","typeGuard":"/** True when the entry can yield a Greenhouse boards-api URL. */\nfunction isGreenhouseDerivable(entry) {\n  if (!entry || typeof entry !== 'object') return false;\n  if (typeof entry.api === 'string') {\n    try { const u = new URL(entry.api); return u.protocol === 'https:'; } catch { return false; }\n  }\n  return typeof entry.careers_url === 'string'\n    && /job-boards(?:\\.eu)?\\.greenhouse\\.io\\/[^/?#]+/.test(entry.careers_url);\n}","tryCatchPattern":"try {\n  const jobs = await greenhouseProvider.fetch(entry, ctx);\n} catch (err) {\n  if (/cannot derive API URL/.test(err.message)) {\n    // Config error, not transient — do NOT retry; log and flag the entry.\n    console.error(`config: ${entry.name} — ${err.message}`);\n  } else {\n    throw err; // network / shape errors should propagate\n  }\n}","preventionTips":["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."],"tags":["config","url-resolution","greenhouse","ats"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}