santifer/career-ops · error · Error

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

Error message

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

What it means

BambooHR's `resolveOrigin` returns null when neither `entry.api` nor `entry.careers_url` yields a `<tenant>.bamboohr.com` origin. `fetch` then throws, naming the entry. `detect()` returns null on the same condition (skipping the provider); this throw only fires if `fetch` is called directly.

Source

Thrown at providers/bamboohr.mjs:65

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

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

  detect(entry) {
    const origin = resolveOrigin(entry);
    return origin ? { url: `${origin}/careers/list` } : null;
  },

  async fetch(entry, ctx) {
    const origin = resolveOrigin(entry);
    if (!origin) throw new Error(`bamboohr: cannot derive API URL for ${entry.name}`);
    const apiUrl = `${origin}/careers/list`;
    assertBambooHRUrl(apiUrl);
    // redirect:'error' + the host check above keep the final hostname pinned to
    // the tenant — a server-side redirect can't bounce us off-domain (SSRF).
    const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' }));
    return parseBambooHRResponse(json, entry.name, origin);
  },
};

/**
 * Parse a BambooHR `/careers/list` response. Exported for unit tests.
 *
 * BambooHR returns:
 *   { meta: {...}, result: [{ id, jobOpeningName,
 *       location: { city?, state? }, isRemote?, employmentStatusLabel? }] }
 *
 * - url: built as `<origin>/careers/<id>` — matches the public
 *   `jobOpeningShareUrl`. Rows without a non-empty `id` are dropped (no stable

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Add `api:` or `careers_url:` pointing to `https://<tenant>.bamboohr.com`.
  2. Verify the company actually uses BambooHR before keeping the provider.
  3. Use `detect()` before `fetch` so unconfigured entries are skipped rather than throwing.

Example fix

# before
- name: Acme
  provider: bamboohr
  careers_url: https://careers.acme.com/

# after
- name: Acme
  provider: bamboohr
  careers_url: https://acme.bamboohr.com/
Defensive patterns

Strategy: type-guard

Validate before calling

// Use detect() before fetch; skip entries that don't resolve
const detected = provider.detect(entry);
if (!detected) {
  console.warn(`bamboohr: skipping ${entry.name} — no <tenant>.bamboohr.com origin (add api/careers_url)`);
  continue;
}

Type guard

const RE = /^[a-z0-9][a-z0-9-]*\.bamboohr\.com$/;
/** @param {any} e */
function hasBambooOrigin(e) {
  const u = e?.api ?? e?.careers_url;
  if (typeof u !== 'string') return false;
  try { return RE.test(new URL(u).hostname); } catch { return false; }
}

Try / catch

try {
  const origin = resolveOrigin(entry);
  if (!origin) throw new Error(`bamboohr: cannot derive API URL for ${entry.name}`);
  // ...fetch
} catch (err) {
  if (/cannot derive API URL/.test(err.message)) { console.warn(err.message); return []; }
  throw err;
}

Prevention

When it happens

Trigger: `resolveOrigin(entry)` returns null — the entry has no `api:` and no `careers_url` on a bamboohr.com host. Thrown at the top of `fetch` before any network call.

Common situations: An entry declares `provider: bamboohr` but only carries a corporate careers_url, the bamboohr fields were omitted, or a YAML indentation error orphaned them.

Related errors


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