santifer/career-ops · error
collage: API URL must be /v1/positions/<job-site-address>
Error message
collage: API URL must be /v1/positions/<job-site-address>: ${url} What it means
assertCollageApiUrl requires the API URL path to match /v1/positions/<job-site-address> — a single non-empty path segment with no slashes, query string, or fragment. The job-site address is an explicit tenant identifier, not a guessed slug, so anything that doesn't fit this exact shape is rejected. This error fires when protocol and host are correct but the path is wrong.
Solutions
- Set the `api:` value to exactly https://api.collage.co/v1/positions/<job-site-address> with no trailing slash, query, or extra segments
- Find the job-site address on the tenant's secure.collage.co/jobs/<address> page (the last path segment) and use that
- If unsure, configure `careers_url: https://secure.collage.co/jobs/<address>` and let resolveApiUrl derive and validate the API URL
Example fix
# before (portals.yml) api: https://api.collage.co/v1/positions/acme?page=1 # after api: https://api.collage.co/v1/positions/acme
Defensive patterns
Strategy: validation
Validate before calling
// Validate the /v1/positions/<site> path shape before configuring
function hasCollagePositionsPath(v) {
try { return /^\/v1\/positions\/[^/?#]+$/.test(new URL(v).pathname); } catch { return false; }
}
Type guard
function isWellFormedCollageApiUrl(v) {
try {
const u = new URL(v);
return u.protocol === 'https:' && u.hostname === 'api.collage.co'
&& /^\/v1\/positions\/[^/?#]+$/.test(u.pathname);
} catch { return false; }
} Try / catch
try {
const jobs = await collageProvider.fetch(entry, ctx);
} catch (err) {
if (String(err.message).includes('API URL must be /v1/positions/')) {
console.error(`${entry.name}: api must be exactly https://api.collage.co/v1/positions/<job-site-address> — no query, trailing slash, or extra segments`);
} else { throw err; }
} Prevention
- Copy the job-site address (last segment of the tenant's secure.collage.co/jobs/... URL) into the API path exactly as-is
- Never append pagination, filters, or trailing slashes to the configured api URL — the provider handles paging itself
- When in doubt, configure careers_url instead and let resolveApiUrl build and validate the API URL
When it happens
Trigger: A portals.yml `api:` value like https://api.collage.co/v1/positions (missing the tenant segment), .../v1/positions/acme/jobs (extra segment), .../v1/positions/acme?page=1 (query string), or an old/renamed endpoint path.
Common situations: Appending pagination or filter parameters to the configured URL (pagination belongs to the provider, not the config); copying a browser URL with extra path segments; missing the tenant id entirely.
Understand the failure class
Background: "Invalid URL" errors: why new URL(), URI.parse, and reqwest::Url reject your string — missing scheme, whitespace, and bad path format — this error's family across 39 libraries.
Related errors
- a16z-speedrun-talent: invalid URL
- agentic-jobs: invalid URL
- collage: invalid URL
- eightfold: cannot derive API URL for
- eightfold: untrusted hostname
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/6de00f85596f8963.
Report an issue: GitHub.
Appendix: source
Thrown at providers/collage.mjs:22
// Collage HR public job-site API. A job-site address is an explicit tenant
// identifier, not a company-name slug we should guess. Entries may provide
// the exact API URL or a public Collage careers URL from which the final path
// segment is read.
const API_ORIGIN = 'https://api.collage.co';
const COLLAGE_API_HOST = 'api.collage.co';
const COLLAGE_SITE_HOST_RE = /^secure\.collage\.co$/;
/** @param {string} url */
function assertCollageApiUrl(url) {
let parsed;
try { parsed = new URL(url); } catch { throw new Error(`collage: invalid URL: ${url}`); }
if (parsed.protocol !== 'https:') throw new Error(`collage: URL must use HTTPS: ${url}`);
if (parsed.hostname !== COLLAGE_API_HOST) {
throw new Error(`collage: untrusted hostname "${parsed.hostname}" — must be ${COLLAGE_API_HOST}`);
}
if (!/^\/v1\/positions\/[^/?#]+$/.test(parsed.pathname)) {
throw new Error(`collage: API URL must be /v1/positions/<job-site-address>: ${url}`);
}
return url;
}
/** @param {import('./_types.js').PortalEntry} entry */
function resolveApiUrl(entry) {
const explicit = typeof entry.api === 'string' ? entry.api.trim() : '';
if (explicit) return assertCollageApiUrl(explicit);
const raw = typeof entry.careers_url === 'string' ? entry.careers_url.trim() : '';
if (!raw) return null;
let parsed;
try { parsed = new URL(raw); } catch { return null; }
if (parsed.protocol !== 'https:' || !COLLAGE_SITE_HOST_RE.test(parsed.hostname)) return null;
if (!/^\/jobs\/[^/]+(?:\/)?$/.test(parsed.pathname)) return null;
const parts = parsed.pathname.split('/').filter(Boolean);
const site = parts.at(-1);
if (!site || site.includes('.')) return null;View on GitHub (pinned to aac998c7ed)