API reference Everything an engineer needs, on one page. No signup to read it, no key to try it.
WHAT THIS IS
A read-only JSON API over 21.2 million manufacturer part records from 1,798 manufacturers. Every record was read from the manufacturer's or distributor's own public product page and carries that page's URL and the date it was read. Nothing is generated, inferred or guessed. A missing value means the source did not publish it.
Base URL https://api.openspecindex.com
Auth X-Api-Key: <key> (or none: see Rate limits)
Format JSON, UTF-8
Versioning /api/v1/ . A breaking change becomes /v2; v1 keeps answering.
Spec https://api.openspecindex.com/openapi.json
HOW TO READ THIS API IN SIXTY SECONDS
Four endpoints, each one narrower than the last. You walk down them until you know enough to ask the real question. Everything except the last step is free and unlimited.
WHAT FAMILIES EXIST? GET /api/v1/catalog.txt plain text, greppable, 91 KB
GET /api/v1/categories?slim=1 the JSON equivalent
WHAT CAN I FILTER ON? GET /api/v1/categories?family=nut&slim=1 types + gate NAMES, 10 KB
WHAT VALUES ARE VALID? GET /api/v1/values/nut/thread_spec one attribute, 1 KB
GIVE ME THE PARTS GET /api/v1/parts?family=nut&attr.thread_spec=1/4-20
GET /api/v1/count?family=nut&... same filters, count only
What comes back is named after the endpoint. /parts returns
parts. /values returns the valid values of one attribute. /count returns two numbers and no
rows. /categories and /catalog.txt return the structure itself, not any data in it.
Path or query string? Required identity goes in the path, optional narrowing
in the query. You cannot ask /values without saying values of what, so family and attribute are path
segments. Every filter on /parts is optional, so they are all query parameters. And a spec filter is
written attr.<name>= because attribute names come from the data rather than from this API; the
prefix keeps a family whose attribute is called limit or type from colliding with the
built-ins.
WORKED EXAMPLE ONE: A LOCK NUT
1. WHICH FAMILY grep it out of /api/v1/catalog.txt
nut 106102 parts Fasteners
nut/hex 21962 thread_spec, material_class
nut/lock/nylon 10940 thread_spec, material_class
The words after the type are its GATES: what defines identity for that part.
2. WHAT VALUES GET /api/v1/values/nut/thread_spec?type=nut/lock/nylon
-> 1/4-20, 5/16-18, 3/8-16, 1/2-13, m6-1, m8-1.25, #10-32 ...
GET /api/v1/values/nut/material_class?type=nut/lock/nylon
-> steel (6879), stainless-steel (3440), brass (87), aluminum (76)
3. COUNT FIRST GET /api/v1/count?family=nut&type=nut/lock/nylon
&attr.thread_spec=1/4-20&attr.material_class=stainless-steel
-> {"manufacturers": 31, "components": 140}
4. THE ROWS same URL with /parts instead of /count
WORKED EXAMPLE TWO: A BALL VALVE
A completely different family, and note that not one gate is shared with the nut. There is no universal set of attributes: identity is defined per part type, which is why the tree has two levels.
1. WHICH FAMILY valves 652180 parts Valves & Flow Control
ball-valve 221962 connection_size_in, body_material_class,
end1_connection_type, end2_connection_type
check-valve 90216 connection_size_in, body_material_class, ...
2. WHAT VALUES GET /api/v1/values/valves/connection_size_in?type=ball-valve
-> 1, 0.5, 0.75, 2, 1.5, 1.25, 3, 0.25 (inches, numeric)
GET /api/v1/values/valves/body_material_class?type=ball-valve
-> brass (36838), pvc (32770), stainless-steel (30637), bronze (25061)
3. COUNT FIRST GET /api/v1/count?family=valves&type=ball-valve
&attr.body_material_class=brass&attr.connection_size_in=0.75
-> {"manufacturers": 72, "components": 2521}
4. THE ROWS same URL with /parts instead of /count
Add one filter at a time and watch the count. Counts are free and unlimited, so when a number drops to zero you know exactly which filter did it. That is the whole debugging technique.
Text values are checked against a list; numbers are not. A misspelled text
value returns 400 with the valid list. A number has no list to check against, so a dimension that nothing states
returns an honest zero: attr.connection_size_in=0.8 finds nothing. Read /values first. A
value that is not a number at all now returns 400 rather than an empty result.
A rating filters as a threshold, not an equality. Dimensions match exactly.
Ratings do not: every attribute publishes a matches sentence, and the ones marked
atLeast or atMost compare with ≥ and ≤. So
attr.pressure_working_max_psi=175 returns everything rated 175 psi and higher. Ask for
what the job needs and better parts still qualify. There is no operator to write.
pressure_class is not a pressure. It is an ASME class, which is
a curve against temperature rather than a number: a Class 150 flange is rated 285 psi at 100°F and
75 psi at 800°F. Use it to identify a part, never to answer whether one is good for a given pressure.
THE DATA MODEL, WHICH IS THE ONLY UNUSUAL PART
Raw catalogue data is inconsistent: every vendor describes a nut differently. So each part is classified into a normalised tree, and that tree is what you query.
FAMILY the aisle nut
PART TYPE the shelf nut/lock/nylon (hierarchical: nut/lock includes nut/lock/nylon)
GATES identity attributes thread_spec, material_class
FACTS everything else grade_class, width_across_flats_in, ...
A gate is an identity claim, not a filter. Two parts must agree on every stated gate to be interchangeable. That distinction is what makes cross-referencing mean something: pooling happens on gates, scoring happens on the rest. Everything else in this document follows from it.
Coverage today: 75 families combed across 989 part types. A family that has not been combed is not served, and says so.
DISCOVERY: YOU NEVER HAVE TO ASK US WHAT IS VALID
GET /api/v1/categories
Returns every family, its part types, the gates on each type, and every valid value with a
count behind it. Build any query from this one response. The values it returns are exactly the values
attr.<name>= accepts. Free and unlimited.
It is 3.7 MB in full, because every valid value of every gate on every type ships with its count. That is right for a client building a filter UI once and wrong for a person reading it. Two narrowings:
GET /api/v1/categories?slim=1 every family, no value lists 922 KB
GET /api/v1/categories?family=nut one family, all its values 112 KB
GET /api/v1/categories?family=nut&slim=1 one family, types and gates 10 KB <- start here
ENDPOINTS
| Endpoint | What it does | Example |
|---|---|---|
GET /api/v1/catalog.txt | Plain text. Every family and part type with its gates, one per line. Greppable. Start here if you are a person. | /api/v1/catalog.txt |
GET /api/v1/categories | The same structure as JSON. Takes ?family= and ?slim=1. | /api/v1/categories?family=nut&slim=1 |
GET /api/v1/values/{family}/{attr} | Valid values for one attribute, with counts, by part type. ~1 KB. Use before filtering on a number. | /api/v1/values/fan/airflow_cfm?type=fan/ventilation |
GET /api/v1/count | How many match. Free and unlimited. Takes every filter /parts does. | /api/v1/count?family=nut&has_cad=true |
GET /api/v1/parts | Structured search. Filter by family, type, gate values, assets. | /api/v1/parts?family=nut&type=nut/lock/nylon&attr.material_class=stainless-steel |
GET /api/v1/parts/{mfg}/{mpn} | One specific part. | /api/v1/parts/bufab/104621020110 |
GET /api/v1/find/{words} | Plain words or a part number in the path. Resolves to a family and gate filters. One call, no query string. | /api/v1/find/316-stainless-nylon-insert-lock-nut |
GET /api/v1/equivalents/{mpn} | Cross-reference: a part number in, interchangeable parts from other manufacturers out. | /api/v1/equivalents/SS-AFSF12 |
FILTERS ON /parts AND /count
| Parameter | Meaning |
|---|---|
family | Combed category slug from /categories. |
type | Part-type slug. Hierarchical: a parent includes its children. Requires family. |
attr.<name> | Gate or spec value, using the slugs /categories returns. Rating attributes are thresholds, so an "at least 150 psi" request is satisfied by a 200 psi part. |
manufacturer | Exact manufacturer slug. |
mpn | Exact manufacturer part number. |
has_cad, has_datasheet, has_image | true keeps only parts with that asset, false only those without, absent means no filter. 6.6M parts carry CAD, 7.4M a datasheet. |
q | Free text across MPN, description and category. Requires a scope (family, type, manufacturer, category or mpn). Unscoped it is refused, because it would scan every row; use /find for plain words instead. |
limit, offset | 1 to 15, and a paging offset. |
WHAT COMES BACK
{
"mpn": "1061-10278-0042",
"manufacturer": { "slug": "albany-county-fasteners", "name": "Albany County Fasteners" },
"partType": "nut/lock/nylon",
"specs": { ... }, // the SOURCE's own labels, in the source's order
"facts": [ { "attr": "thread_spec", "value": "#10-32", "tier": 1, "sourceKey": "Thread Size" } ],
"assets": { "cad": [...], "datasheets": [...], "images": [...] },
"priceEach": 0.24,
"package": { "stated": "Box of 100", "qty": 100 },
"source": { "url": "https://...", "checkedAt": "2026-07-05T00:00:00.000Z" }
}
specs is what the vendor wrote. facts is the normalised view, tiered:
tier 1 is gates, tier 2 scored specs, tier 3 the rest. sourceKey says which raw field each fact came from,
so you can always get back to the original. package exists because a published price is often for a box,
not a piece, and an integration that assumes otherwise will be wrong by 100x.
ERRORS: A TYPO IS AN ERROR, NOT AN EMPTY RESULT
Zero results is a wrong answer to a mistyped filter, and the caller cannot tell the difference from the outside. So unknown families, types and attributes return 400 with the valid options listed.
GET /api/v1/parts?family=nut&attr.color=red
400 { "error": "unknown attribute \"color\" for family \"nut\"; valid: thread_spec,
thread_dia_unspec, material_class, material_grade, grade_class, ..." }
GET /api/v1/find/flux-capacitor
404 { "not_combed_yet": ["capacitor"],
"message": "We have not combed capacitors yet, so we will not guess..." }
TWO FIELDS THAT ARE NOT DECORATION
understood.ignored_words on /find lists the words that matched
nothing and were not applied as filters. A filter silently dropped is worse than a filter refused, because
the caller believes it was applied.
Text values are validated against a list; numbers are not. A misspelled
text value returns 400 with the valid list. A number has no list, so a dimension nothing states returns an honest
zero: attr.airflow_cfm=32 matches only parts stating exactly 32. Call
/api/v1/values/{family}/{attr} first and pick one that exists. Ratings are the exception and compare
as thresholds, not equalities, so attr.pressure_working_max_psi=175 also returns everything rated
higher. The matches field on every attribute says which behaviour applies.
specsCompared and specsStated on every /equivalents
result. A match percentage never travels without its coverage: 100% agreement on one spec out of three is not a
100% match, and both numbers are returned so you can tell them apart.
RATE LIMITS
Counts, categories and discovery are free and unlimited, with or without a key. Row pulls are metered. Without a key you are identified by IP and get 100 lifetime row pulls to try it. Over the limit a data request degrades to a free match count rather than a 429, so an integration always receives an answer. Keyed accounts have configurable per-minute, hour, day and lifetime limits.
LIMITATIONS, STATED PLAINLY
- 75 of ~890 families are combed. Anything else is refused as an unknown family rather than answered from an adjacent one.
- Match quality tracks gate depth. A part stating eight gates cross-references precisely; a part stating three generic ones matches broadly. The coverage fields tell you which you are looking at.
- Row responses cap at 15. Counts are unlimited, so paginate against a count.
- Pricing is partial. Where a source published a price we carry it, with the package it applies to. Many did not.
- No SLA today. Ask before you depend on it.
Machine-readable spec at openapi.json. Agent-oriented instructions at llms.txt. A live query console is on the API page.