# Google Sheets

> How do I pull BetterJobs results into a Google Sheet?

Source: https://docs.betterjobs.cc/integrations/google-sheets/

BetterJobs has no Sheets add-on. You add a small Apps Script function to your sheet. It calls the API with `UrlFetchApp` and returns a table, so you can type this in a cell:

```text
=BETTERJOBS_SEARCH("Head of RevOps, Head of Revenue Operations", "DE, AT, CH", 7, 25)
```

The result spills into the cells below and to the right: one header row, then one row per canonical job.

> Before you start
>
> You need a live API key (`bj_live_...`). See [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md).

## Set it up

1. In your sheet, open **Extensions → Apps Script**.

2. Open **Project Settings** (the gear icon). Under **Script properties**, add a property named `BETTERJOBS_API_KEY` with your key as the value. The key stays out of the code and out of the sheet.

3. Back in the editor, replace the contents of `Code.gs` with the script below and save.

4. In any cell, type `=BETTERJOBS_SEARCH(...)` as shown above.

## The script

```js
const BETTERJOBS_URL = 'https://api.betterjobs.cc/v1/jobs/search';
const BETTERJOBS_VERSION = '2026-10-01';


/**
 * Searches BetterJobs and returns one row per canonical job.
 *
 * @param {string} titles Comma-separated title keywords, e.g. "Head of RevOps, Head of Revenue Operations".
 * @param {string} countries Comma-separated ISO country codes, e.g. "DE, AT, CH".
 * @param {number} days Jobs first seen within this many days (1-365).
 * @param {number} limit Jobs to return (1-100). Also the credit cap for this cell.
 * @return {Array<Array<string|number>>} Header row plus one row per job.
 * @customfunction
 */
function BETTERJOBS_SEARCH(titles, countries, days, limit) {
  const key = PropertiesService.getScriptProperties().getProperty('BETTERJOBS_API_KEY');
  if (!key) throw new Error('Add the BETTERJOBS_API_KEY script property first.');
  if (!titles || !countries || !days || !limit) {
    throw new Error('Usage: =BETTERJOBS_SEARCH(titles, countries, days, limit)');
  }


  const list = (value) => String(value).split(',').map((s) => s.trim()).filter((s) => s);
  const body = {
    filters: {
      title_or: list(titles),
      country_code_or: list(countries).map((c) => c.toUpperCase()),
      posted_within_days: Number(days),
    },
    waterfall: { strategy: 'cheapest_first', max_credits: Number(limit) },
    limit: Number(limit),
  };


  const res = UrlFetchApp.fetch(BETTERJOBS_URL, {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + key, 'BetterJobs-Version': BETTERJOBS_VERSION },
    payload: JSON.stringify(body),
    muteHttpExceptions: true,
  });
  const json = JSON.parse(res.getContentText());
  if (res.getResponseCode() !== 200) {
    throw new Error(json.error.code + ': ' + json.error.message + ' (' + json.error.request_id + ')');
  }


  const header = ['title', 'company', 'domain', 'country', 'seniority', 'posted_at', 'first_seen_at', 'apply_url', 'sources', 'p_real', 'id'];
  const rows = json.data.map((job) => [
    job.title,
    job.company.name,
    job.company.domain ?? '',
    job.location.country_code ?? '',
    job.seniority ?? '',
    job.posted_at ?? '',
    job.first_seen_at,
    job.apply_url ?? '',
    job.sources.map((s) => s.provider).join('|'),
    job.p_real,
    job.id,
  ]);
  return [header, ...rows];
}
```

Empty cells mean unknown (`null` in the API), not “none”. `sources` lists every provider that saw the job, joined with `|`, the same format as the [sample CSV](https://docs.betterjobs.cc/data/samples.md). Each column is described in the [field dictionary](https://docs.betterjobs.cc/data/field-dictionary.md).

If the request fails, the cell shows `#ERROR!`. Hover it to read the error code and message, for example `insufficient_credits: ...`. The request id in brackets is what support needs. Error codes are listed on [Errors](https://docs.betterjobs.cc/platform/errors.md).

## Control what it costs

Each cell is a live API call. Read this before you copy the formula down a column.

> Sheets re-runs custom functions
>
> Sheets runs the function again when its arguments change, and it can run it again when the file is reopened. Each run is a new request:
>
> - Jobs you already paid for come back free (`metadata.jobs_already_paid`).
> - Jobs that appeared since the last run are new unique jobs, and cost 1 credit each.
>
> `limit` doubles as `max_credits`, so one cell never costs more than `limit` credits per run. To freeze a result, copy the range and use **Paste special → Values only**, then delete the formula.

- A search with no matches returns only the header row and costs 0 credits.
- Duplicates merged across providers are free. You pay once per unique job.
- To preview cost first, send the same body with `"dry_run": true`. The response has `estimate.credits_range` and costs nothing. See [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

## Try it without a key

Change `BETTERJOBS_URL` to `https://api.betterjobs.cc/v1/sandbox/jobs/search` and remove the key check and the `Authorization` header. The sandbox returns fixed illustrative data and charges nothing. It is good for laying out the sheet before you spend credits. Switch back to the live URL when you are done.

## Limits

- One cell returns at most 100 jobs, the maximum page size. For more, use an [async search](https://docs.betterjobs.cc/platform/async-searches.md) from code and import the result as CSV.
- Apps Script stops a custom function after 30 seconds. The API’s default `timeout_ms` is 10 seconds, so a search finishes well within that. Slow providers are dropped and the result is `partial`.
- Many cells recalculating at once can hit your rate limit (`429 rate_limited`). Keep the number of live formulas small. See [Rate limits](https://docs.betterjobs.cc/platform/rate-limits.md).

## Related

- [Find companies hiring](https://docs.betterjobs.cc/guides/find-companies-hiring.md) for the filters behind this formula.
- [Filters](https://docs.betterjobs.cc/platform/filters.md) for `seniority_or`, `remote` and the rest. Add them to `body.filters` in the script.
- [Troubleshooting](https://docs.betterjobs.cc/resources/troubleshooting.md) if the sheet stays empty.
