santifer/career-ops · error · Error

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

Error message

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

What it means

fetch() throws when resolveApiUrl(entry) returns null. resolveApiUrl returns null if entry.careers_url is missing/non-string, unparseable, not HTTPS, or its hostname fails RECRUITEE_HOST_RE. The derived API endpoint would be https://<slug>.recruitee.com/api/offers/. This fires at fetch time when the entry cannot produce that endpoint.

Source

Thrown at providers/recruitee.mjs:51

    return null;
  }
  if (parsed.protocol !== 'https:') return null;
  if (!RECRUITEE_HOST_RE.test(parsed.hostname)) return null;
  return `https://${parsed.hostname}/api/offers/`;
}

/** @type {Provider} */
export default {
  id: 'recruitee',

  detect(entry) {
    const apiUrl = resolveApiUrl(entry);
    return apiUrl ? { url: apiUrl } : null;
  },

  async fetch(entry, ctx) {
    const apiUrl = resolveApiUrl(entry);
    if (!apiUrl) throw new Error(`recruitee: cannot derive API URL for ${entry.name}`);
    assertRecruiteeUrl(apiUrl);
    const json = await ctx.fetchJson(apiUrl, { redirect: 'error' });
    return parseRecruiteeResponse(json, entry.name);
  },
};

/**
 * Parse a Recruitee /api/offers/ response. Exported for unit tests.
 *
 * Recruitee returns:
 *   { offers: [{ title, careers_url?, url?, city?, country?, remote?, location? }] }
 *
 * - url: prefer `careers_url`, fall back to `url`. Recruitee tenants commonly
 *   serve postings on their own custom domain (e.g. `careers.hostaway.com`),
 *   so this URL is NOT host-locked to `*.recruitee.com`. Unlike the API
 *   endpoint, the per-offer URL is display-only — it is written to the pipeline
 *   and scan history but never server-fetched here, so the SSRF rationale does
 *   not apply. It is sourced from the already-validated tenant API response.

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Provide a valid https://<slug>.recruitee.com careers_url on the entry.
  2. Call detect(entry) before fetch() and skip when it returns null.
  3. Verify the config field name is careers_url (not career_url or url).
  4. If the board uses a branded domain, switch to the correct provider or supply the recruitee.com subdomain explicitly via api:.

Example fix

// before
await provider.fetch({ name: 'Acme' }, ctx);
// after
if (provider.detect(entry)) await provider.fetch(entry, ctx);
Defensive patterns

Strategy: validation

Validate before calling

if (!provider.detect(entry)) {
  console.warn(`skip ${entry.name}: recruitee cannot derive API URL`);
  continue;
}
await provider.fetch(entry, ctx);

Type guard

null

Try / catch

try {
  await provider.fetch(entry, ctx);
} catch (e) {
  if (/cannot derive API URL/.test(e.message)) {
    console.warn(`[skip] ${entry.name}: missing or non-recruitee careers_url`);
  } else throw e;
}

Prevention

When it happens

Trigger: entry.careers_url is undefined or empty; it is http:// (not https); its hostname is not *.recruitee.com; it is a malformed string that throws inside new URL().

Common situations: A job_boards row lacks careers_url; detect() returned null but fetch was called anyway; a config rename dropped the URL field; the entry references a branded domain incompatible with recruitee auto-detection.

Related errors


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