Your key crosses the wire exactly once
We store a SHA-256 hash of your API key and nothing else. What that buys, what it costs, and the CORS trap that breaks browser auth.
You create a key in the dashboard. It renders once. You copy it, or you don't — and if you don't, nobody can show it to you again. Not you. Not support. Not whoever has production database access.
That's deliberate. It's worth understanding what it buys, because it also costs you something.
What actually gets stored#
A key is the prefix hj_live_ followed by 48 hex characters — 24 random bytes.
The plaintext crosses the wire exactly once, at creation. What we keep is a
SHA-256 hash of it.
Key created
hj_live_ + 48 hex
Shown once
copy it now
SHA-256 stored
the hash, not the key
Plaintext gone
unrecoverable by anyone
A hash is one-way. The row in our database is not a key and cannot be turned back into one — we authenticate by hashing what you send and comparing digests, which never requires the original. There's no recovery flow because it would require us to hold what we deliberately threw away.
A database breach can't leak working keys. The price is that losing a key means rotating it rather than looking it up.
If you lose one, the only procedure is: revoke it, create another, deploy. That inconvenience has a known cost. The alternative — a store we could read back to you — has an unbounded one, paid by everyone at once, on a day we don't get to choose.
Support will never ask for your key — there's nothing we could do with it that we can't do without it. A key pasted into a ticket is a key in an email thread, a ticketing system, and someone's inbox backup. Never send it to us.
You can hold up to 10 active keys at a time. Revoked keys don't count against that limit. That ceiling is what makes rotation possible without downtime.
Two ways to send it#
Authorization: Bearer <key> | ?api_key=<key> | |
|---|---|---|
| Precedence | Wins if both are present | Fallback |
| Where the key lands | The header, and nowhere else | The URL — proxy logs, browser history, Referer |
| Works from a browser | No — see below | Yes |
| When to use it | Everywhere you can | When you have no other option |
Prefer the header. A key in a query string gets written to every log between you and us, and those logs outlive the request by however long someone's retention policy says — not a number you know.
The Bearer prefix is optional. Send it anyway.#
The server strips Bearer if it's there and otherwise treats the whole header
value as the key. Authorization: hj_live_… works.
Send Bearer regardless. Not because we need it — because every other tool in
the ecosystem expects it. Clients, proxies and gateways have all read the same
RFC, and the first one to choke on a bare-key header will do it somewhere you
weren't looking. The eight characters aren't worth the afternoon.
The CORS failure, mechanically#
This is the one that wastes the most time, so here's the mechanism rather than the rule.
Every response carries Access-Control-Allow-Origin: *. So a GET from page
JavaScript reaches the API — permission granted, wildcard, on every response.
Then you add your key as an Authorization header, and it stops working.
CORS splits requests into simple and non-simple. A simple request goes
straight out and the browser checks the response headers afterwards. A non-simple
one gets checked first: the browser sends an OPTIONS request to the same
URL asking whether the real request would be allowed. That's the preflight.
A custom Authorization header is exactly what makes a request non-simple. So
the browser preflights — and the API has no OPTIONS handler. The preflight
arrives as a request nothing is there to answer, is treated as unauthenticated,
and fails.
Two details make this maximally confusing:
- The preflight doesn't carry your key. It only describes the header you intend to send, so there is no world in which it authenticates, however correct your key is.
- The failure isn't a
401. The browser blocks the real request because the preflight never approved it, so yourfetchnever sends it. What lands in yourcatchis a network or CORS error with nothing useful in it.
Your key was fine. Your request was never sent.
A ?api_key= request sends no custom headers. It stays simple, no preflight
happens, and it just works.
"It just works" is the trap#
A browser-side key is a public key
Anything in browser JavaScript is readable by anyone who opens devtools. Minification is not obfuscation, an environment variable inlined at build time is a string in your bundle, and "it's only inside a fetch call" means it's in the network tab.
A key shipped to the browser is a key you have published.
And it carries your whole quota. Rate limits are per
account, not per key — every key on the account draws from one shared pool. So
a leaked key doesn't just burn itself, it burns the pool every other key is
drinking from, and your production traffic starts getting 429s because of a
bundle you shipped last quarter. Revoking it is the easy half; you still have
to ship a new one to every browser that has the old one.
Call the API from your server and expose your own endpoint to your frontend.
The ?api_key= fallback exists because it's the only thing that works from a
browser. That's a statement about CORS, not an endorsement.
Why 401 tells you nothing#
401 comes back when the key is missing, malformed, unknown, or revoked. The
response doesn't distinguish:
{ "error": "invalid or missing api key", "hint": "Authorization: Bearer <key>" }That's on purpose. An auth error that distinguishes is an oracle. "Malformed" versus "unknown" confirms whether a guessed value has the right shape; "unknown" versus "revoked" confirms that a string someone is holding was once a real, working key. Both hand free information to the one person who shouldn't have it.
The cost lands entirely on you. The same four words cover a typo, an unset
variable that made your header the literal Bearer undefined, a key you revoked
in March and forgot about, and one that was never yours. The API will not narrow
it down. That's a defensible trade for an auth error and a bad one for a
debugging experience, and it's both at once.
Narrow it locally instead. A key is hj_live_ plus 48 hex characters — 56 in
total. Assert the length and the prefix in your config loader before you blame
the server, and you'll catch most of the four cases without a request.
Verifying a key#
Not with /v1/health. It never reads one — it's the only endpoint that needs no
key at all, so a revoked key, a truncated key and no key whatsoever all get the
same 200. Health-checking with a broken key confirms the API is up, which was
never the question.
The cheapest real check is one authenticated row:
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
"https://api.hyperjobs.io/v1/jobs?limit=1"200 means the key is live. 401 means it isn't.
/v1/meta does the same job. Read the status code,
not the body — a key problem and an empty result set are identical if you only
inspect data.
Scopes, honestly#
Keys carry a jobs:read scope. It's currently the only scope, every endpoint is
readable with it, and there's nothing to configure. The field exists so write or
admin scopes can be added later without reissuing keys.
So it does nothing for you today. It's forward-compatibility — a promise about our future migration cost, not a feature you can use. Worth recognising in a payload. Not worth a line of code.
Rotating without downtime#
There's no automatic rotation. The 10-key ceiling is the budget that makes the manual procedure safe — you're meant to run two keys at once for a while.
Create the new key
The old one keeps working while you roll. Copy the new one straight into your secret store — it's shown once, same as the first.
Deploy it
Update your secret store and let the change propagate everywhere. Everywhere is the load-bearing word — the monthly cron, staging, the analytics job nobody has touched in a year.
Confirm the old key is idle
The dashboard shows
last_used_atper key. Wait until the old key stops moving. This step is the entire reason the procedure has no downtime — it's the only evidence you have that nothing is still holding the old key.Revoke the old key
Revocation takes effect on the next request and is permanent. The key's usage history is retained for your billing records.
Skipping step three is how a rotation becomes an outage. Revoke first and
last_used_at stops being a check and starts being a postmortem — you'll still
discover the forgotten consumer, just from its 401s in production.
The full reference is authentication.
Keep reading
The same role posted twice is one job
A role mirrored to Greenhouse and LinkedIn is one posting, not two. What deduplication has to decide, and what the API lets you see.
7 min read
Why every job carries its company
Most jobs APIs hand you an employer name and leave you to resolve it. Here is what changes when the company object is already attached.
6 min read
This API tells you when you're wrong
A bad parameter is a 400 that names it, never a silent drop. Why strict validation is the kindest thing an API can do with your quota.
8 min read