# n8n

> How do I call BetterJobs and receive its webhooks in n8n?

Source: https://docs.betterjobs.cc/integrations/n8n/

BetterJobs has no native n8n node. You use two built-in nodes:

- **HTTP Request** to call the API.
- **Webhook** plus a **Code** node to receive events and check their signature.

> Before you start
>
> You need a live API key (`bj_live_...`). See [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md). Webhook deliveries are listed on the Pro plan and above. Check yours on [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

## Store the API key as a credential

1. In n8n, create a credential of type **Header Auth**.
2. Set **Name** to `Authorization`.
3. Set **Value** to `Bearer bj_live_...` with your key.
4. Save it as `BetterJobs`. Every HTTP Request node below uses it.

Keeping the key in a credential keeps it out of exported workflow JSON.

## Search jobs with the HTTP Request node

1. Add an **HTTP Request** node.

2. Set:

   | Setting        | Value                                                   |
   | -------------- | ------------------------------------------------------- |
   | Method         | `POST`                                                  |
   | URL            | `https://api.betterjobs.cc/v1/jobs/search`              |
   | Authentication | Generic Credential Type → Header Auth → `BetterJobs`    |
   | Send Headers   | On. Name `BetterJobs-Version`, value `2026-10-01`       |
   | Send Body      | On. Body Content Type `JSON`, Specify Body `Using JSON` |

3. Paste the body:

   ```json
   {
     "filters": {
       "title_or": ["Head of RevOps", "Head of Revenue Operations"],
       "country_code_or": ["DE", "AT", "CH"],
       "posted_within_days": 7
     },
     "waterfall": {
       "strategy": "cheapest_first",
       "max_credits": 100
     },
     "limit": 25
   }
   ```

   To use a value from an earlier node, switch the field to expression mode and insert it, for example `"company_domain_or": ["{{ $json.domain }}"]`.

4. Add a **Split Out** node after it. Set **Field To Split Out** to `data`. You now get one n8n item per canonical job.

Each item has the [Job fields](https://docs.betterjobs.cc/data/field-dictionary.md): `id`, `title`, `company.domain`, `apply_url`, `sources` and so on. Store `id`. Fetching the same job later is free.

### Page through results

One request returns at most 100 jobs. For more, turn on pagination in the HTTP Request node under **Options → Pagination**:

| Setting                  | Value                                       |
| ------------------------ | ------------------------------------------- |
| Pagination Mode          | Update a Parameter in Each Request          |
| Type                     | Body                                        |
| Name                     | `cursor`                                    |
| Value                    | `{{ $response.body.next_cursor }}`          |
| Pagination Complete When | Other                                       |
| Complete Expression      | `{{ $response.body.next_cursor === null }}` |

Also set **Max Pages** so a broad filter cannot page forever. `waterfall.max_credits` caps each request, not the whole run. See [Pagination](https://docs.betterjobs.cc/platform/pagination.md).

> More than a few hundred jobs?
>
> Use an async search instead of paging. Send `POST /v1/searches` with `webhook_url` set to an n8n Webhook node URL. When the search ends you receive `search.completed`, then read results with `GET /v1/searches/{id}`. Without webhooks, poll that endpoint with a **Wait** node in a loop and branch on `status`. See [Async searches](https://docs.betterjobs.cc/platform/async-searches.md).

### Handle errors and rate limits

- Under the node’s **Settings**, turn on **Retry On Fail** for `429` and `500`. A `500` is safe to retry: send the same `Idempotency-Key` header and you are never charged twice. See [Idempotency](https://docs.betterjobs.cc/platform/idempotency.md).
- If you call BetterJobs once per input item, set **Options → Batching** so items are sent in small batches with an interval. That keeps you under your rate limit. See [Rate limits](https://docs.betterjobs.cc/platform/rate-limits.md).
- `200` with `metadata.status: "partial"` is not an error. One provider failed or timed out. You still get the other results and pay only for those. See [Provider status](https://docs.betterjobs.cc/resources/provider-status.md).

## Receive webhooks

BetterJobs POSTs one event per request to your endpoint. It signs each delivery with the `BetterJobs-Signature` header. Check the signature before you act on the event.

### 1. Add the Webhook node

1. Add a **Webhook** node. Set **HTTP Method** to `POST` and pick a path, for example `betterjobs`.
2. Set **Respond** to `Immediately`. BetterJobs needs a `2xx` within 10 seconds. A slow workflow would otherwise cause retries.
3. Under **Options**, turn on **Raw Body**. The signature covers the exact bytes BetterJobs sent. Parsed and re-serialized JSON will not match.
4. Copy the **Production URL**. Use it as `webhook_url` when you create a watch (`POST /v1/watches`) or an async search.

### 2. Check the signature in a Code node

Add a **Code** node after the Webhook node. Set **Mode** to `Run Once for All Items` and **Language** to JavaScript.

```js
// Verifies BetterJobs-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
const crypto = require('crypto');


const secret = $env.BETTERJOBS_WEBHOOK_SECRET;
if (!secret) throw new Error('BETTERJOBS_WEBHOOK_SECRET is not set');


const TOLERANCE_SECONDS = 300;
const out = [];


for (let i = 0; i < $input.all().length; i++) {
  const item = $input.all()[i];
  const header = item.json.headers['betterjobs-signature'];
  if (!header) continue;


  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const raw = (await this.helpers.getBinaryDataBuffer(i, 'data')).toString('utf8');


  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${raw}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 ?? '', 'hex');
  const signatureOk = a.length === b.length && crypto.timingSafeEqual(a, b);
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= TOLERANCE_SECONDS;


  if (signatureOk && fresh) out.push({ json: JSON.parse(raw) });
}


return out;
```

The node passes on only events with a valid signature, as parsed JSON. Invalid deliveries are dropped. Check the Webhook node’s output once to confirm the binary property is named `data`. If not, change the second argument of `getBinaryDataBuffer`.

> Self-hosted n8n
>
> The Code node blocks Node built-ins by default. Start n8n with `NODE_FUNCTION_ALLOW_BUILTIN=crypto`. Set `BETTERJOBS_WEBHOOK_SECRET` as an environment variable, and make sure `$env` access is not blocked by `N8N_BLOCK_ENV_ACCESS_IN_NODE`. Do not paste the secret into the code.

The 300-second tolerance on `t` rejects old deliveries replayed by someone else. Full details are on [Webhooks](https://docs.betterjobs.cc/platform/webhooks.md).

### 3. Route by event type

Add a **Switch** node on `{{ $json.type }}`. These three types matter most for outbound:

| Event          | What happened                                                                            | What to do                                                             | data                                             | Cost     |
| -------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ | -------- |
| `job.opened`   | A new canonical job appeared for a watched company or saved search.                      | Trigger outbound. This is the only event that should start a sequence. | job (full Job)                                   | 1 credit |
| `job.reposted` | The same job was re-listed. repost\_count went up.                                       | Do not re-trigger outbound. Update your copy of the job only.          | job (id, title, company, status, repost\_count)  | Free     |
| `job.closed`   | The job is no longer live. closed\_reason says why: filled, expired, removed or unknown. | Stop sequences tied to this job. Mark it closed in your CRM.           | job (id, title, company, status, closed\_reason) | Free     |

`job.opened` is the only event that should start a sequence. Never re-trigger on `job.reposted`. All event types are in the [event catalog](https://docs.betterjobs.cc/data/events.md).

### Failed deliveries

If your workflow is off or errors before it responds, BetterJobs retries with backoff for 24 hours. After you fix it, re-send any event with `POST /v1/webhooks/replay`. Replays are free. You can also read every event from `GET /v1/events` as a fallback.

## Related

- [Detect hiring changes](https://docs.betterjobs.cc/guides/detect-hiring-changes.md) for watches and events end to end.
- [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md) for daily upserts by job `id`.
- [Troubleshooting](https://docs.betterjobs.cc/resources/troubleshooting.md) for signature mismatches and empty results.
