{"record":{"id":"cb53824fba6eb377","repo":"santifer/career-ops","slug":"recruitee-cannot-derive-api-url-for-entry-name","errorCode":null,"errorMessage":"recruitee: cannot derive API URL for ${entry.name}","messagePattern":"recruitee: cannot derive API URL for (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/recruitee.mjs","lineNumber":53,"sourceCode":"    return null;\n  }\n  if (parsed.protocol !== 'https:') return null;\n  if (!RECRUITEE_HOST_RE.test(parsed.hostname)) return null;\n  return `https://${parsed.hostname}/api/offers/`;\n}\n\n/** @type {Provider} */\nexport default {\n  id: 'recruitee',\n\n  detect(entry) {\n    const apiUrl = resolveApiUrl(entry);\n    return apiUrl ? { url: apiUrl } : null;\n  },\n\n  async fetch(entry, ctx) {\n    const apiUrl = resolveApiUrl(entry);\n    if (!apiUrl) throw new Error(`recruitee: cannot derive API URL for ${entry.name}`);\n    assertRecruiteeUrl(apiUrl);\n    const json = await ctx.fetchJson(apiUrl, { redirect: 'error' });\n    return parseRecruiteeResponse(json, entry.name);\n  },\n};\n\n/**\n * Parse a Recruitee /api/offers/ response. Exported for unit tests.\n *\n * Recruitee returns:\n *   { offers: [{ title, careers_url?, url?, city?, country?, remote?, location? }] }\n *\n * - url: prefer `careers_url`, fall back to `url`. Recruitee tenants commonly\n *   serve postings on their own custom domain (e.g. `careers.hostaway.com`),\n *   so this URL is NOT host-locked to `*.recruitee.com`. Unlike the API\n *   endpoint, the per-offer URL is display-only — it is written to the pipeline\n *   and scan history but never server-fetched here, so the SSRF rationale does\n *   not apply. It is sourced from the already-validated tenant API response.","sourceCodeStart":35,"sourceCodeEnd":71,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/recruitee.mjs#L35-L71","documentation":"Like the pinpoint provider, recruitee's fetch() derives its API URL (https://<slug>.recruitee.com/api/offers/) from the entry's careers_url via resolveApiUrl(). If that returns null — no careers_url, unparseable, non-HTTPS, or non-matching hostname — fetch() throws this error rather than silently producing no jobs.","triggerScenarios":"fetch() called for an entry with a missing/empty careers_url, a malformed URL, an http: URL, or a hostname that fails the <slug>.recruitee.com regex (e.g. a custom careers domain).","commonSituations":"Config entry for a company that left Recruitee (or serves its board on a custom domain); careers_url omitted while the entry is routed to the recruitee provider; typo in the slug.","solutions":["Set careers_url to https://<slug>.recruitee.com for the tenant","If the company uses a custom domain, resolve it (DNS CNAME) to the underlying recruitee.com host and use that","Ensure the URL is valid https: and matches the single-label slug pattern","Confirm the company actually uses Recruitee; otherwise switch providers"],"exampleFix":"// before (portals.yml)\ncareers_url: https://acme.com/careers\n// after\ncareers_url: https://acme.recruitee.com","handlingStrategy":"validation","validationCode":"const RE = /^[a-z0-9][a-z0-9-]*\\.recruitee\\.com$/;\nfunction recruiteeReady(entry) {\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';\n  if (!raw) return false;\n  try { const u = new URL(raw); return u.protocol === 'https:' && RE.test(u.hostname); } catch { return false; }\n}","typeGuard":"function isRecruiteeEntry(entry) {\n  return typeof entry?.careers_url === 'string'\n    && /^https:\\/\\/[a-z0-9][a-z0-9-]*\\.recruitee\\.com\\/?$/.test(entry.careers_url);\n}","tryCatchPattern":"try {\n  await recruitee.fetch(entry, ctx);\n} catch (err) {\n  if (err.message.includes('cannot derive API URL')) {\n    console.warn(`Skipping ${entry.name}: careers_url is not a <slug>.recruitee.com URL`);\n    return [];\n  }\n  throw err;\n}","preventionTips":["Store https://<slug>.recruitee.com in careers_url — not the company's custom careers domain","Check ATS migrations: if a company leaves Recruitee, update or remove its entry","Pre-flight validate every portals.yml entry against the provider host regex"],"tags":["config","url-validation","ssrf-protection"],"backgroundTag":"invalid-url","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}