The JSON string that never shipped
employment_type almost went live as JSON text inside JSON. The post explaining why we couldn't fix it is what convinced us to fix it.
Revised 2026-07-16 for the v1 launch. This post originally ran as "The JSON string in your JSON", documenting a field that came back as the text of an array — and arguing, at length, that our own compatibility policy forbade fixing it. The draft it described never shipped. This is the story of the argument losing.
job.employment_type === "FULL_TIME" was always false. Not usually. Always —
including on every one of the four-hundred-thousand-plus postings that are
full-time.
That was the payload a pre-release draft of this API served. The comparison
didn't throw and didn't warn; it returned false, your if took the other
branch, and the bug shipped. Here's what the object looked like:
{
"employment_type": "[\"FULL_TIME\"]",
"education_requirements": "[\"bachelor degree\"]",
"skills": ["typescript", "graphql", "postgresql"],
"taxonomies": ["Software"]
}Four fields, all lists of tokens. Two arrays, and two strings containing the
text of an array. There was no principle separating them — not size, not
nullability, not versioning. skills and taxonomies got parsed on the way
out; the other two didn't, and nobody noticed before the shape reached the
documentation.
Board publishes
a list of employment types
Stored as TEXT
encoded once
Returned verbatim
"[\"FULL_TIME\"]"
JSON.parse
in every client, forever
The shipped v1 returns what the draft should have:
{
"employment_type": ["FULL_TIME"],
"education_requirements": ["bachelor degree"],
"benefits": ["Equity", "Health, dental & vision"],
"skills": ["typescript", "graphql", "postgresql"],
"taxonomies": ["Software"]
}Every list field is an array — benefits included, which the draft served as
prose. job.employment_type.includes("FULL_TIME") works with no decode step,
no boundary function, no parseList helper copied between projects. If you
kept a JSON.parse from the draft-era docs, delete it: parsing an array throws.
Why we almost didn't fix it#
The original version of this post made a case we believed while writing it, and it's worth restating because the reasoning was correct — only the premise was wrong.
The compatibility policy says changing a field's type is a
breaking change, and breaking changes don't happen inside /v1. A client that
read the docs and wrote JSON.parse(job.employment_type) was correct, and
"fixing" the field breaks exactly the people who did the reading — loudly,
since JSON.parse on an array is a TypeError, not a graceful degrade. So the
post concluded: the field is ugly, the promise matters more, employment_type
is a string for the life of /v1.
All of that holds — if anyone has shipped against the field. Nobody had. The draft had documentation and zero production consumers, which meant the policy wasn't yet protecting anyone; it was only protecting the bug. A compatibility promise is a contract with your users, not a suicide pact with your own typos, and the day before you have users is the last day you can say so. We fixed it, and the policy now protects the right shape from the launch forward.
The lesson we kept: the draft-era docs said, in bold, "write your client as though it is permanent." Documentation that has to talk like that is a code review comment addressed to the wrong audience.
It really is a list#
The part of the original post that survives untouched, because it was never about the encoding.
A posting can carry several employment types at once —
["FULL_TIME","TEMPORARY"] is a full-time contract role — and 20+
combinations occur in the data. The ?employment_type=
filter matches a value inside the list, which
is why FULL_TIME finds both ["FULL_TIME"] and ["FULL_TIME","TEMPORARY"].
And since commas mean OR,
?employment_type=CONTRACTOR,TEMPORARY is the union — postings carrying
either.
The array was always the honest shape. That's what made serving its
JSON.stringify so grating: you weren't unwrapping ceremony to reach a string,
you were recovering a list that was a list all along.
What you send is not what you read back#
Two asymmetries from the draft era survive, both worth knowing:
| Field | You send | You read back |
|---|---|---|
employment_type | ?employment_type=FULL_TIME | ["FULL_TIME"] |
education_requirements | ?education=bachelor degree | ["bachelor degree"] |
You filter with a bare value and read back an array — the filter tests
membership, not equality. And the parameter for the second field is
?education=, not ?education_requirements= — it filters the field of the
longer name, but it isn't called that.
The vocabularies still have opposite conventions sitting side by side:
employment_type is SCREAMING_SNAKE_CASE (FULL_TIME, PART_TIME,
CONTRACTOR, TEMPORARY, INTERN, PER_DIEM, VOLUNTEER, OTHER);
education is lower-case with spaces (bachelor degree, high school,
postgraduate degree, professional certificate, associate degree). The
difference is what happens when you mix them up. The draft returned zero rows
and let you conclude nobody's hiring; v1 returns a
400 with the allowed values in the hint, because both fields
are closed vocabularies and the server knows them.
The flip side of the same policy#
This half of the original post is unchanged, because it was right the first time.
New enum values ship at any time, and they are not breaking
New taxonomies, new source values as ATS integrations land, new
data_tier values — all backwards-compatible by policy, and they can
appear without notice. So can new fields on the Job object.
A client with an exhaustive switch that throws on the default case will
break on a change that is, by policy, not breaking. That's not a bug in the
policy. It's a bug in the client.
Don't fail on an unknown enum value or an unrecognised field. Log it, skip it, render it as-is — anything but throw.
Read the two halves together and they're one rule seen from both ends: the
types are frozen so you can trust them, and the values are open so you can't
enumerate them. v1's launch shape is the shape for the life of /v1 — that
promise is now load-bearing, because this time there are clients on the other
end of it. Parse against the type. Never assume the value set is closed.
While you're in there#
The JSON-in-JSON is gone, but the payload still has shapes worth a second look. Three quick ones:
descriptionis absent, not null, unless you pass?description_format=textorhtml. So"description" in jobisfalseandjob.descriptionisundefined.locationis never null, becauseremotealways resolves to a real boolean.if (job.location)is always true and tells you nothing — testjob.location.countries?.lengthinstead.openingsis absent unless you pass?collapse=true.
And the null rule that outranks all of them: education_requirements: null
means the posting said nothing — not "no degree required". A missing
classification is silence, not a negative.
Field-by-field types and nullability live in the Job object.
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