santifer/career-ops · error · Error
recruitee: cannot derive API URL for
Error message
recruitee: cannot derive API URL for ${entry.name} What it means
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.
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
Example fix
// before (portals.yml) careers_url: https://acme.com/careers // after careers_url: https://acme.recruitee.com
Defensive patterns
Strategy: validation
Validate before calling
const RE = /^[a-z0-9][a-z0-9-]*\.recruitee\.com$/;
function recruiteeReady(entry) {
const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';
if (!raw) return false;
try { const u = new URL(raw); return u.protocol === 'https:' && RE.test(u.hostname); } catch { return false; }
} Type guard
function isRecruiteeEntry(entry) {
return typeof entry?.careers_url === 'string'
&& /^https:\/\/[a-z0-9][a-z0-9-]*\.recruitee\.com\/?$/.test(entry.careers_url);
} Try / catch
try {
await recruitee.fetch(entry, ctx);
} catch (err) {
if (err.message.includes('cannot derive API URL')) {
console.warn(`Skipping ${entry.name}: careers_url is not a <slug>.recruitee.com URL`);
return [];
}
throw err;
} Prevention
- 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
When it happens
Trigger: 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).
Common situations: 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.
Understand the failure class
Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.
Related errors
- himalayas: untrusted hostname
- itviec: untrusted hostname
- jobbankca: invalid URL
- jobspresso: invalid URL
- jobstreet: invalid URL
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/cb53824fba6eb377.
Report an issue: GitHub.
Appendix: source
Thrown at providers/recruitee.mjs:53
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 aac998c7ed)