# Authentication

One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.

**What this covers:** The header; Three accepted spellings, and why; What a rejected key looks like; Creating, naming, rotating and revoking.

**Assumed knowledge:** That you have a key, or have read [the quickstart](./quickstart.md#qs-get-key).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/authentication  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="auth-bearer"></a>

## 1. The header

Authorization: Bearer <your key>, on every /v1 request. Nothing at all on /public — a key there is neither required nor useful.

### 1.1 In depth

There is one credential and it is the key's secret. There is no client id, no secret pair, no OAuth flow, no token exchange and no expiry to refresh. A key is created on the dashboard, it works until you revoke it, and it identifies a plan and a meter.

The secret is shown exactly once, when the key is created. It is stored as a SHA-256 hash, so nobody can recover it — if you lose it, rotate the key and it keeps its identity, its plan and its usage history while getting a new secret.

> **Server side only** — A key in browser JavaScript, in a mobile app bundle, or in a public repository is a published key, and the record meter bills whoever holds it. `/public/*` exists precisely so that front-end code has something to call without one.

### 1.2 Exact contract

The canonical form

```http
GET /v1/jobs?country=IE&limit=25 HTTP/1.1
Host: api.jobopportunitiesapi.org
Authorization: Bearer sk_live_your_secret_here
Accept: application/json
```

The key travels in the header, never in the query string. A key in a URL ends up in browser history, in proxy logs, in `Referer` headers and in anything that records URLs, and there is no parameter here that accepts one.

### 1.3 Worked examples

A minimal authenticated request

```console
$ export JOA_KEY='your-api-key-here'
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?country=IE&limit=2' | jq '.data[].title'
"Senior Software Engineer"
"Customer Success Manager"
```

_Titles will differ; this request is re-sent against the live API on every build of this site._

**See also**

- [Three accepted spellings, and why](./api-authentication.md#auth-tolerant)
- [What a rejected key looks like](./api-authentication.md#auth-failure-modes)
- [What you get without an account](./api-keyless.md#keyless-what-you-get)

<a id="auth-tolerant"></a>

## 2. Three accepted spellings, and why

Bearer <key>, bearer <key> and the bare <key> with no scheme are all accepted. Bearer is canonical; the tolerance exists for integration platforms.

### 2.1 In depth

| Header value | Accepted | Comment |
| --- | --- | --- |
| `Bearer sk_…` | Yes | **The canonical form.** Write this. |
| `bearer sk_…` | Yes | RFC 7235 requires the scheme token to be matched case-insensitively anyway. |
| `BEARER sk_…` | Yes | Same rule. |
| `sk_…` | Yes | The bare key, no scheme. Shipped 2026-08-22. |
| `?key=sk_…` in the query string | No | There is no such parameter, deliberately. |
| `X-Api-Key: sk_…` | No | Not read. |

The bare form is not sloppiness, it is a measured response to how the key is actually pasted. Integration platforms — Clay, Bubble, n8n, and every other product with a “paste your API key” box — give the user one field and send its contents verbatim. Refusing that produced a 401 whose only clue was the error message, and generated support mail for a case where the user had done nothing wrong.

Both spellings hash to the same lookup, so nothing about the security boundary moves. Document and write `Bearer`; expect the bare form to work if a tool sends it.

### 2.2 Exact contract

The secret is hashed with SHA-256 and compared against the stored hash. There is no prefix-based lookup shortcut that could leak timing information about which keys exist, and a revoked or expired key takes exactly the same path as an unknown one.

**See also**

- [What a rejected key looks like](./api-authentication.md#auth-failure-modes)
- [Creating, naming, rotating and revoking](./api-authentication.md#auth-keys-lifecycle)

<a id="auth-failure-modes"></a>

## 3. What a rejected key looks like

Missing, unknown, revoked and expired keys all return the same 401 with the same body. The API will not tell a prober which kind of wrong they have.

### 3.1 In depth

Distinguishing “this key was valid once” from “this key never existed” is useful to exactly one person, and it is not you. So all four failures are identical: same status, same error code, same message.

The practical consequence is that when a 401 surprises you, the debugging order is: check the header spelling, check you are on `api.jobopportunitiesapi.org`, check the key on the dashboard, and only then suspect the key itself.

### 3.2 Exact contract

The 401, exactly as returned

```console
$ curl -s https://api.jobopportunitiesapi.org/v1/jobs
{
  "error": "unauthorized",
  "message": "Send your key as: Authorization: Bearer YOUR_API_SECRET
               — the key on its own is accepted too.",
  "docs": "https://jobopportunitiesapi.org/api"
}
```

_Captured 2026-08-22. HTTP 401._

| Situation | Status | `error` | What to do |
| --- | --- | --- | --- |
| No `Authorization` header | 401 | `unauthorized` | Add it. |
| Unknown secret | 401 | `unauthorized` | Check the dashboard. |
| Revoked key | 401 | `unauthorized` | Create or rotate a key. |
| Expired key | 401 | `unauthorized` | Same. |
| Valid key, endpoint not on your plan | 403 | `upgrade_required` | `X-JOA-Required-Feature` names the missing entitlement — `delta_feed` or `bulk_export`. |
| Valid key, month's records spent | 402 | `record_quota_exhausted` | **Do not retry.** See [the record meter](./account-record-meter.md#meter-exhausted). |
| Valid key, too many requests | 429 | — | Wait `Retry-After` seconds. |

**See also**

- [Every status code](./api-errors.md#error-table)
- [402 — the licence, not the throttle](./api-errors.md#error-402)
- [The two entitlements](./account-plans.md#plan-entitlements)

<a id="auth-keys-lifecycle"></a>

## 4. Creating, naming, rotating and revoking

Keys are managed on the dashboard. Rotating replaces the secret and keeps everything else; revoking is permanent and the row is kept for audit.

### 4.1 In depth

- **Create** — **Create a free key** on [/dashboard](https://jobopportunitiesapi.org/dashboard), or buy a plan at [/pricing](https://jobopportunitiesapi.org/pricing). The secret is shown once.
- **Name** — Keys can be named, so a list of them is readable. The name is yours and is not sent anywhere.
- **Rotate** — Issues a new secret for the same key. The plan, the meter and the usage history stay attached. Use this after a leak or a lost secret — it is not the same as creating a new key.
- **Revoke** — Immediate and permanent. The key row is kept rather than deleted, because usage attached to it has to stay auditable.
- **Cancel** — Cancels the subscription behind a paid key. See [Cancelling](./account-sign-in.md#cancel-subscription).

You can hold more than one key. The usual reason is to separate environments — a key for production and one for a staging job — so that revoking one does not stop the other. The record allowance is per key, not per account, so splitting also splits the meter.

### 4.2 Exact contract

Key management is done through the website's session, not through the API key itself: the endpoints behind it (`POST /public/auth/keys`, `POST /public/auth/keys/{id}/rotate`, `.../revoke`, `.../name`) require the signed session cookie the magic link creates, and a key cannot manage itself. That is deliberate — a leaked key cannot be used to rotate away from you.

`GET /v1/me` is the one place a key can inspect itself: it returns the plan, the status, the per-day and per-minute limits, the created date, the key prefix and today's usage. It costs no records and is safe to poll.

**See also**

- [GET /v1/me](./endpoints-meta.md#endpoint-me)
- [Rotating a key](./account-sign-in.md#key-rotation)
- [Every control on the dashboard](./account-sign-in.md#dashboard-tour)

---

## Where to go next

This file is part of **API**. Others in the same group:

- [API overview](./api-overview.md) — Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Rate limits](./api-rate-limits.md) — Two independent ceilings — requests per minute and per day — plus a monthly record allowance that is a licence rather than a throttle. They fail differently.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.
- [Errors](./api-errors.md) — Every status the API emits, its JSON shape, whether it is worth retrying, and what to do about it. Two of them are routinely confused and it is expensive.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
