The skill you were shown is not the skill you filter on
Skills go out display-formatted and come in as canonical tokens. Why ?skill=Machine Learning returns zero rows, and the heuristic that isn't an inverse.
A response hands you a skills array. Something like this — illustrative, but the shape is exact:
{ "skills": ["Machine Learning", "pytorch", "CI/CD"] }You render all three as chips. A user clicks one. You send it straight back:
curl -G https://api.hyperjobs.io/v1/jobs \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
--data-urlencode "skill=Machine Learning"200 OK. total: 0. Click pytorch instead and you get rows. Same array, same
request shape, two different outcomes — and nothing in either response tells you
which one you're looking at.
The value the API showed you is not the value the API accepts. That is the whole post.
Skills are tokens, not text#
Skills are stored as canonical lower-case tokens with no spaces and no
punctuation. Not free text, not the words that appeared in the posting — a
normalised token drawn from a fixed vocabulary. Machine Learning in a job
description becomes the token machinelearning, and that token is the only
string in the database.
So ?skill= is a token match. Not a substring match. That is the opposite of
every other text filter on the same endpoint:
| Parameter | Matching | What that means |
|---|---|---|
title= | Substring | java finds JavaScript Engineer |
description= | Substring | Same — it matches anywhere in the body |
skill= | Token | java does not match javascript |
And the sharper edge of the same rule: skill=py matches nothing at all.
Not "few results" — zero. There is no prefix search on skills, no stemming, no
partial anything. You send a token or you send nothing.
Two filters on one endpoint with opposite matching modes is a genuine
inconsistency, and it is worth saying so rather than explaining it away. It is
also defensible in one direction: skills is a closed-ish vocabulary, and
substring-matching a vocabulary would make java silently return every
javascript posting — a superset you never asked for and can't detect. Exact
tokens make skill=java mean Java. The cost is that the filter is unforgiving in
exactly the way title= is forgiving, and the API never returns a
400, so being wrong looks identical to being right about an empty
niche.
Two alias tables, pointing opposite ways#
Here is why the token model isn't enough to explain the zero. There are two translation layers, and they are not inverses of each other.
| Query aliases | Display aliases | |
|---|---|---|
| Direction | Input — the value you send | Output — the skills array on the way out |
| When | Rewrites before the query runs | Formats a token for a human to read |
| Size | 8 entries | Not enumerated |
| Reverse mapping | n/a | None |
Display aliases are one-way. There is no reverse table, so a value the API rendered is not necessarily a value the API will resolve. Feed it back and you get an empty set that looks exactly like "no jobs match".
What makes this ship to production rather than getting caught in an afternoon is
that most tokens have no display alias at all. The Job object
example comes back as ["typescript", "graphql", "postgresql"] — raw tokens,
which round-trip perfectly. Your chip component works. It works for weeks. Then
someone clicks the one chip that got formatted.
The round trip, documented#
Straight from the skills guide:
| You see | Actually stored as | ?skill= with what you saw | ?skill= with the token |
|---|---|---|---|
Machine Learning | machinelearning | 0 rows | 22,269 |
CI/CD | cicd | 0 rows | 4,178 |
Quality Assurance | qualityassurance | 0 rows | ✓ |
C++ | cpp | ✓ — input-aliased | ✓ |
Three of those four rows are the failure. The fourth is the exception, and the exception is the confusing part.
- machinelearning
- 22,269
- cpp
- 9,586
- csharp
- 6,309
- dotnet
- 4,912
- cicd
- 4,178
Why c++ works and Machine Learning doesn't#
?skill=c++ returns 9,586 rows. ?skill=c# returns 6,309. ?skill=.net
returns 4,912. ?skill=node.js resolves too. All punctuated, all fine — which
looks like evidence that the API is tolerant about punctuation.
It isn't. The punctuated forms are not stored. c++ as a stored token is 0
rows. c# is 0. Those queries work because the input alias rewrites them to
cpp, csharp, dotnet and nodejs before the query ever runs. The token
model is intact; a table of eight entries is papering over four of its corners.
That is the trap, stated precisely: only those eight query-alias entries
survive the round trip. Everything else that gets display-formatted does not.
C++ works and CI/CD doesn't, and there is nothing about the two strings that
predicts which is which. You are pattern-matching on a lookup table you cannot
see.
The heuristic, and what it isn't#
If a displayed skill contains a space or a slash, strip them and lower-case it before filtering:
// Turns a displayed skill back into a queryable token.
const toToken = (displayed) => displayed.replace(/[\s/]/g, "").toLowerCase();
toToken("Machine Learning"); // → "machinelearning"
toToken("CI/CD"); // → "cicd"Be clear about what that is. It is a heuristic, not the inverse function — no such function exists. Display aliasing has no reverse mapping, so there is no correct way to recover a token from a rendered string; there is only a rule that covers the observed failures. Spaces and slashes are the whole observed failure mode, so the rule earns its place. It is still a guess with a good track record, not a guarantee.
The robust version of your chip component doesn't call toToken at click time at
all. It keeps the token it filtered with, renders the string the API sent, and
never asks the display string to do a second job. Round-tripping through the UI
is the bug; carrying the token alongside it is the fix.
Picking a classifier, and not picking three#
Skills are one of three ways to ask "what kind of job is this", and they trade off against each other:
| Use | Vocabulary | Coverage | Cost |
|---|---|---|---|
taxonomies | 28 fixed Title-Case values | 64% | Coarse; a job family, not a capability |
skill | Open, canonical tokens | 84% | Specific, but tokens only |
title | Free text | 100% | Substring, no stemming, noisy |
skill is the highest-signal option with the best coverage of the three, so
start there for anything technology-shaped. Use taxonomies to scope a broad
query to a sector, not to find things — a third of postings carry no taxonomy and
are invisible to it.
Don't reach for all three defensively. Coverage doesn't stack, it intersects. Every classifier you add is another AND, and each one silently drops the postings it never classified. 84%, 64% and 100% do not combine into reassurance; they combine into a much smaller set than you expect, with nothing in the response to say so.
The same applies inside skill itself — it's the one filter where comma still
means AND:
# Tagged BOTH python AND aws. Not "either".
curl "…/v1/jobs?skill=python,aws"Every other multi-value filter treats commas as OR,
but skills compose requirements, so skill intersects. For a union across
skills, run one request per value and merge on id — N values costs N requests
against your rate limit.
The diagnostic#
When a skill filter returns a number you don't believe, ask in this order. Did
the displayed string contain a space or a slash — that's toToken. Is it
punctuated and not one of the eight — then it was never going to work. Did you
comma-join values expecting OR — on skill, uniquely, you asked for AND. And if
total didn't move at all when you added the filter, check what the filter
actually tests — skill is an open vocabulary, so a
token nothing carries is a silent zero, not a 400.
The token table, the alias tables and the full round-trip rules live in skills & taxonomies.
Keep reading
The comma means OR, except for skill
taxonomies=Software,Healthcare is a union now. Why v1 made the comma OR on every multi-value filter but one — and why skill still intersects.
8 min read
How to measure remote hiring share
The query that answers it, and the ways it still lies to you: two remote fields, sparse classifiers, provenance mix, and the archive tail.
7 min read
Reading salary data honestly
What the salary fields mean, why coverage is partial by construction, and what salary_gte's range-overlap semantics do and don't promise.
8 min read