OpenSpec Index
GET IN TOUCH

Questions, pilots, API access. One email reaches the builder.

Open mail app

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

EndpointWhat it doesExample
GET /api/v1/catalog.txtPlain 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/categoriesThe 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/countHow many match. Free and unlimited. Takes every filter /parts does./api/v1/count?family=nut&has_cad=true
GET /api/v1/partsStructured 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

ParameterMeaning
familyCombed category slug from /categories.
typePart-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.
manufacturerExact manufacturer slug.
mpnExact manufacturer part number.
has_cad, has_datasheet, has_imagetrue keeps only parts with that asset, false only those without, absent means no filter. 6.6M parts carry CAD, 7.4M a datasheet.
qFree 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, offset1 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

Machine-readable spec at openapi.json. Agent-oriented instructions at llms.txt. A live query console is on the API page.