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
- Provide a valid https://<slug>.recruitee.com careers_url on the entry.
- Call detect(entry) before fetch() and skip when it returns null.
- Verify the config field name is careers_url (not career_url or url).
- 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
- Gate fetch() on detect() returning non-null.
- Ensure every recruitee entry has a string https://<slug>.recruitee.com careers_url.
- Validate config field names (careers_url, not url).
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
- pinpoint: cannot derive API URL for ${entry.name}
- radancy: cannot resolve search-jobs URL for ${entry.name}
- rheinmetall: cannot resolve vacancies URL for ${entry.name}
- rippling: cannot derive API URL for ${entry.name}
- justjoin: careers_url or api must be a trusted justjoin.it U
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/cb53824fba6eb377.
Report an issue: GitHub.