santifer/career-ops · error

greenhouse: cannot derive API URL for

Error message

greenhouse: cannot derive API URL for ${entry.name}

What it means

The Greenhouse provider's fetch() derives the API URL either from an explicit entry.api or from entry.careers_url. If neither yields a usable API URL, resolveApiUrl returns null and fetch throws this error naming the portal entry. It catches config entries that cannot be served by the Greenhouse API path at all.

Solutions

  1. Add an explicit api: https://boards-api.greenhouse.io/<board-token>/jobs to the entry in portals.yml.
  2. Verify the company actually uses Greenhouse; if not, switch the entry's provider to the correct one.
  3. Check that the careers_url is a greenhouse.io URL so resolveApiUrl can derive the API host from it.
  4. Inspect resolveApiUrl in providers/greenhouse.mjs to confirm which URL shapes it supports.

Example fix

// before (portals.yml)
- name: Acme
  provider: greenhouse
  careers_url: https://acme.com/careers
// after
- name: Acme
  provider: greenhouse
  api: https://boards-api.greenhouse.io/acme/jobs
Defensive patterns

Strategy: validation

Validate before calling

function greenhouseEntryReady(entry) {
  return Boolean(entry && (entry.api || entry.careers_url));
}
if (!greenhouseEntryReady(entry)) console.warn(`entry ${entry.name} lacks api/careers_url for greenhouse`);

Type guard

const hasApiOrCareersUrl = (e) => typeof e?.api === 'string' || typeof e?.careers_url === 'string';

Try / catch

try {
  await provider.fetch(entry, ctx);
} catch (err) {
  if (new RegExp(`greenhouse: cannot derive API URL for ${entry.name}`).test(err.message)) {
    console.error(`Add api: https://boards-api.greenhouse.io/<token>/jobs to entry "${entry.name}"`);
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: A job_boards entry with provider: greenhouse whose entry has no api field and whose careers_url is absent, empty, or not in a form resolveApiUrl can convert into a boards-api.greenhouse.io URL (e.g. a vanity domain or a non-Greenhouse careers page).

Common situations: portals.yml entry added with only a human careers page URL that is not a greenhouse.io host, an entry with a typo'd/blank key (api:/careers_url misspelled), or an entry copied from a different provider's format.

Understand the failure class

Background: "Invalid value" and "allowed values are" config errors: what your library rejected and how to fix it — this error's family across 41 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/02d8e34e89d732dc. Report an issue: GitHub.

Appendix: source

Thrown at providers/greenhouse.mjs:155

  return htmlToText(content);
}

/** @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);
    // content=true embeds each posting's body in the list response (one
    // request, no per-job detail fetches). searchParams.set is idempotent, so
    // an entry.api that already pins the param can't end up with a duplicate.
    const listUrl = new URL(apiUrl);
    listUrl.searchParams.set('content', 'true');
    // Re-validate the final href: the guard chain runs on the exact string
    // that goes over the wire, not just the pre-param base.
    const listHref = assertGreenhouseUrl(listUrl.href);
    // 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(listHref, { 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))) {

View on GitHub (pinned to aac998c7ed)