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 never

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. 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.
  2. 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).
  3. Confirm the slug by opening https://boards-api.greenhouse.io/v1/boards/{slug}/jobs in a browser; a {"jobs":[...]} payload means it is correct.
  4. 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

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


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/02d8e34e89d732dc. Report an issue: GitHub.