OpenSpec Index
GET IN TOUCH

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

Open mail app

Parts database API 21 million manufacturer part records over plain JSON. No key, no signup. The pitch, the live console and the whole reference, on one page.

HAVE A KEY?

Send it as a header, not a query parameter: query strings end up in browser history, proxy logs and shared screenshots, and a header does not.

curl -H "X-Api-Key: <your key>" \
  "https://api.openspecindex.com/api/v1/parts?family=nut&limit=15"
from openspec import OpenSpec

client = OpenSpec(api_key="<your key>")
page = client.parts(family="nut", limit=15)

If a header is not an option where you are working (a browser address bar, a tool that only takes a URL), ?key=<your key> as a query parameter also works, just avoid it anywhere the URL might get logged or shared.

Or set OPENSPEC_API_KEY in the environment and drop the argument: OpenSpec() picks it up on its own, and sends it as a header rather than in the URL.

A key raises your row cap per page and lifts the keyless free-tier totals; count, categories, manufacturers and values stay free and unlimited either way, keyed or not. Need a key?

PYTHON: INSTALL AND FIRST RUN

Everything on this page works with plain curl and needs nothing installed. If you would rather write Python, the official client is pip install openspec: no key for the free tier, no dependencies, Python 3.9+. Get it working here, then use the EXAMPLES IN toggle to read every example below in Python. The library itself is documented under the Python client.

INSTALL, FROM NOTHING

If you have never installed a Python package before, do these four steps in order and it will work. Type the commands into Terminal on macOS or Linux, or Command Prompt on Windows. The $ and > are the prompt, do not type them.

1. Check you have Python. 3.9 or newer.

# macOS and Linux
$ python3 --version
Python 3.12.3

# Windows
> py --version
Python 3.12.3

If that says command not found or a version below 3.9, install Python first from python.org/downloads. On Windows, tick “Add python.exe to PATH” in the installer; skipping that box is the single most common reason the next step fails.

2. Make a virtual environment. A private folder for this project's packages. Not optional on current Debian, Ubuntu, Mint or Homebrew Python: those refuse to install into the system Python and will stop with error: externally-managed-environment, which is PEP 668 protecting the operating system, not a fault in this package.

# macOS and Linux
$ python3 -m venv openspec-venv

# Windows
> py -m venv openspec-venv

3. Install into it. Call the venv's own Python and ask it to run pip. Written this way on purpose: pip on its own is the single biggest source of “it installed but the import fails”, because a bare pip can belong to a different Python than the one you end up running.

# macOS and Linux
$ openspec-venv/bin/python -m pip install openspec

# Windows
> openspec-venv\Scripts\python -m pip install openspec

Nothing else gets installed with it. The client uses only the Python standard library, on purpose: a parts lookup should not drag a dependency tree into a CAD plugin, a procurement script, or a locked-down corporate Python.

4. Run your script with that same Python. Save the code below as parts.py in the folder you are standing in, then:

# macOS and Linux
$ openspec-venv/bin/python parts.py

# Windows
> openspec-venv\Scripts\python parts.py

You may have seen activate elsewhere. It is a shortcut that lets you type plain python for the rest of that terminal session. It does the same thing as the full paths above and it is easy to forget you are in it, so the paths are written out here. pipx is the wrong tool for this: it installs command line applications, and this is a library you import.

FIRST RUN: PROVE IT WORKS

Start with a call that is free and unlimited, so the only thing it can test is whether the install worked:

from openspec import OpenSpec

client = OpenSpec()
print(client.count(family="nut"), "nuts on file")

A number means you are done: installed, connected, no key needed. count(), categories() and values() never cost anything and never run out, so they are the right things to explore with.

Now pull actual rows:

for part in client.find("316 stainless nylon insert lock nut"):
    print(part.mpn, part.manufacturer.name, part.source_url)
IF THAT ONE RAISES RateLimited

Nothing is broken and you did nothing wrong. Rows are metered per IP and counts are not, so on a shared or office connection the row budget can already be spent before you start. The exception is deliberate: the alternative is handing you an empty list that looks like no such part exists, which is a wrong answer wearing a right answer's clothes.

from openspec import RateLimited

try:
    for part in client.find("316 stainless nylon insert lock nut"):
        print(part.mpn, part.manufacturer.name, part.source_url)
except RateLimited as e:
    print("out of free row pulls:", e)
    print("counts still work:", client.count(family="nut"))

Counts keep working either way. For rows, wait for the daily reset or get a key.

WHEN IT DOES NOT WORK

What you seeWhat it means, and the fix
externally-managed-environmentYour OS is protecting the system Python. Use the virtual environment in step 2. Do not reach for --break-system-packages.
command not found: python3
'py' is not recognized
Python is not installed, or not on PATH. Install from python.org and tick Add python.exe to PATH.
No module named openspecIt installed into a different Python than the one you are running. Use the venv's own interpreter for both, exactly as in steps 3 and 4.
No module named pipRare, and means the venv was built without pip. Delete the folder and redo step 2, or add --upgrade-deps.
RateLimitedNot an error in your code. The free row budget for your IP is spent. Counts still work; see above.
BadRequest with a list of valuesA filter value that does not exist. The message carries the legal values, so read it: that list is the most useful thing in the response.
Permission deniedYou are installing outside the venv. Everything above stays inside one folder you own, so nothing needs admin rights.

THE WHOLE API IN ONE CALL

Live, no key: api.openspecindex.com/api/v1/find/10-32-nylock-stainless-nut. Put your own words after /find/.

curl "https://api.openspecindex.com/api/v1/find/10-32-nylock-stainless-nut"
from openspec import OpenSpec

client = OpenSpec()
for part in client.find("10-32 nylock stainless nut"):
    print(part.mpn, part.manufacturer.name, part.source_url)

Building agents? Some assistant fetch tools only open URLs that already appeared in a search result or the user's message, and will refuse a path the agent constructs. The endpoint is public and keyless and answers any correctly formed request, so the workaround is to have the user paste the URL, or to use the links every response already hands back under specsheet.

Python preferred? pip install openspec and skip the URL building entirely. The official client handles escaping, keys, paging and parsing, and it has no dependencies. Read the Python client section, then use the toggle above to read every example on this page in Python.

RUN A QUERY AGAINST THE LIVE DATABASE

queries the full live database · 100 free row pulls per visitor
hit RUN to query the live database (keys: manufacturer, category, family, type, attr.<name>, q, mpn, limit, offset)

This is the real API, live against the full database: match counts are free, rows are capped at 15 with the total alongside (100 free pulls per visitor, more with an API key). The same API is served for programmatic use at api.openspecindex.com (instructions at /llms.txt, spec at /openapi.json).

FOUR QUESTIONS, FOUR ENDPOINTS

Reading a URL

https://api.openspecindex.com/api/v1/parts?family=nut&type=nut/hex&attr.material_class=steel
└───────────── where you are going ─────────────┘└─────────── what you are asking for ───────────┘

   ?   starts the list of filters
   &   separates one filter from the next
       each filter is name=value

type and attr. are different kinds of filter. family, type, limit and manufacturer are built-in: this API defines them and the list never changes. attr.<name> is a spec filter, and the name after the dot comes from the data. A nut has attr.thread_spec; a valve has attr.body_material_class; a bearing has attr.bore_diameter_mm. The prefix keeps the two apart, so a family whose attribute is called limit or type cannot collide with the built-ins. If you read the name out of the catalog, it takes attr. in front of it. The Python client does this for you: a bare keyword argument that is not a built-in is sent as attr.<name>.

Four questions, each narrower than the last. Everything except the last row is free and unlimited. The URLs below are canonical; every one of them was run against the live API.

Your questionCall thisYou get back
What families exist? https://api.openspecindex.com/api/v1/catalog.txt Every family and part type with its gates, as plain text you can read or grep. 91 KB. The JSON form is /categories.
What can I filter on? https://api.openspecindex.com/api/v1/categories?family=<family>&slim=1
https://api.openspecindex.com/api/v1/categories?family=nut&slim=1 nut/hex → thread_spec, material_class https://api.openspecindex.com/api/v1/categories?family=valves&slim=1 ball-valve → connection_size_in, body_material_class, end1_connection_type, end2_connection_type https://api.openspecindex.com/api/v1/categories?family=bearing&slim=1 bearing/ball/deep-groove → bore_diameter_mm, outside_diameter_mm, width_mm, closure, precision_class
One family's part types and the gate names on each. 10 KB, against 3.7 MB for everything. Note that no two families share a gate.
What values are valid? https://api.openspecindex.com/api/v1/values/<family>/<attr>
https://api.openspecindex.com/api/v1/values/nut/material_class?type=nut/hex text → steel, stainless-steel, brass, nylon, aluminum https://api.openspecindex.com/api/v1/values/valves/end1_connection_type?type=ball-valve conn → npt-female, barb, flanged, sweat, push-to-connect https://api.openspecindex.com/api/v1/values/bearing/bore_diameter_mm?type=bearing/ball/deep-groove num → 25, 8, 20, 6, 5, 30
Every valid value for one attribute, with part counts, grouped by type. About 1 KB. Also returns matches, a sentence saying how that attribute compares: text and conn match exactly and a typo returns the valid list; num is a dimension and matches exactly; atLeast and atMost are ratings and match as thresholds, so asking for 175 psi also returns everything rated higher.
Give me the parts. https://api.openspecindex.com/api/v1/parts?family=<family>&attr.<gate>=<value>
https://api.openspecindex.com/api/v1/parts?family=nut&type=nut/hex&attr.thread_spec=1/2-13&attr.material_class=stainless-steel 170 parts from 35 manufacturers https://api.openspecindex.com/api/v1/parts?family=valves&type=ball-valve&attr.connection_size_in=1&attr.end1_connection_type=npt-female 6,326 parts from 49 manufacturers https://api.openspecindex.com/api/v1/parts?family=bearing&type=bearing/ball/deep-groove&attr.bore_diameter_mm=25&attr.closure=open 143 parts from 5 manufacturers
The rows. Swap /parts for /count on any of these for the totals only, which stays free.

The same four steps, in code:

curl "https://api.openspecindex.com/api/v1/catalog.txt"
curl "https://api.openspecindex.com/api/v1/categories?family=nut&slim=1"
curl "https://api.openspecindex.com/api/v1/values/nut/material_class?type=nut/hex"
curl "https://api.openspecindex.com/api/v1/count?family=nut&type=nut/hex&attr.material_class=stainless-steel"
curl "https://api.openspecindex.com/api/v1/parts?family=nut&type=nut/hex&attr.material_class=stainless-steel"
client.catalog_text()
client.categories("nut", slim=True)
client.values("nut", "material_class", type="nut/hex")
client.count(family="nut", type="nut/hex", material_class="stainless-steel")
client.parts(family="nut", type="nut/hex", material_class="stainless-steel")

THE REFERENCE

EXAMPLES IN

A read-only JSON API over 21.2 million manufacturer part records from 1,798 manufacturers. Every record carries a source URL and the date the record was last verified against it. 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

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.

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 on this page follows from it.

Coverage today: 75 families verified across 989 part types. A family that has not been through spec verification is not served, and says so.

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
from openspec import OpenSpec
client = OpenSpec()

# 1. WHICH FAMILY. The plain-text catalog, same bytes as /api/v1/catalog.txt.
print(client.catalog_text())

#      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
client.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 ...
client.values("nut", "material_class", type="nut/lock/nylon")
#   -> steel (6879), stainless-steel (3440), brass (87), aluminum (76)

# 3. COUNT FIRST. Free and unlimited.
client.count(family="nut", type="nut/lock/nylon",
             thread_spec="1/4-20", material_class="stainless-steel")     # 140

# 4. THE ROWS. Same arguments, parts() instead of count().
page = client.parts(family="nut", type="nut/lock/nylon",
                    thread_spec="1/4-20", material_class="stainless-steel")

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
# 1. WHICH FAMILY
cat = client.category("valves")
[t.type for t in cat.part_types][:3]     # 'ball-valve', 'check-valve', ...
[a.attr for a in cat.gates]              # the identity attributes

#      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
client.values("valves", "connection_size_in", type="ball-valve")
#   -> 1, 0.5, 0.75, 2, 1.5, 1.25, 3, 0.25          (inches, numeric)
client.values("valves", "body_material_class", type="ball-valve")
#   -> brass (36838), pvc (32770), stainless-steel (30637), bronze (25061)

# 3. COUNT FIRST
client.count(family="valves", type="ball-valve",
             body_material_class="brass", connection_size_in=0.75)       # 2521

# 4. THE ROWS
page = client.parts(family="valves", type="ball-valve",
                    body_material_class="brass", connection_size_in=0.75)

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.

HOW VALUES MATCH

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, and attr.airflow_cfm=32 matches only parts stating exactly 32. Read /values first. A value that is not a number at all 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 a part rated higher still satisfies it. There is no operator to write, because the direction belongs to the attribute rather than to your query.

curl "https://api.openspecindex.com/api/v1/count?family=elbow&attr.pressure_working_max_psi=175"    # 8051
curl "https://api.openspecindex.com/api/v1/count?family=elbow&attr.pressure_working_max_psi=1000"   # 2163
curl "https://api.openspecindex.com/api/v1/count?family=elbow&attr.pressure_working_max_psi=5000"   #  338

# which behaviour applies: read the matches sentence on the attribute
curl "https://api.openspecindex.com/api/v1/values/elbow/pressure_working_max_psi"
client.count(family="elbow", pressure_working_max_psi=175)    # 8051
client.count(family="elbow", pressure_working_max_psi=1000)   # 2163
client.count(family="elbow", pressure_working_max_psi=5000)   #  338

# which behaviour applies
attr = client.category("elbow").attribute("pressure_working_max_psi")
attr.kind          # 'atLeast'
attr.is_threshold  # True
attr.matches       # 'Pass a number. Matches parts rated AT LEAST that number...'

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.

DISCOVERY: YOU NEVER HAVE TO ASK US WHAT IS VALID

/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
client.categories(slim=True)              # every family, no value lists      922 KB
client.categories("nut")                  # one family, all its values        112 KB
client.categories("nut", slim=True)       # one family, types and gates        10 KB   <- start here

client.category("nut")                    # the same one family, unwrapped, raises NotFound
client.values("nut", "thread_spec")       # one attribute only, about 1 KB

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/compareTwo to eight parts side by side: what they all state identically, where they differ, and what each seller publishes. Rows are tagged fact (normalized, comparable across manufacturers) or spec (one seller's own wording). Reports what was published; makes no claim that the parts interchange./api/v1/compare?parts=bufab/104621020110,ace-hardware/101134
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

FILTERS ON /parts AND /count

The Python client takes the same names as keyword arguments. Anything that is not one of the built-ins below is sent as attr.<name>, so client.parts(family="nut", thread_spec="1/4-20") and ?family=nut&attr.thread_spec=1/4-20 are the same request.

ParameterMeaning
familyVerified 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.

Paging is the same idea either way: ask for a page, then ask for the next one. The Python client wraps it, stops on the first empty page, and stops rather than hammering once the free tier is spent.

curl "https://api.openspecindex.com/api/v1/parts?family=screw&attr.thread_spec=1/4-20&limit=15&offset=0"
curl "https://api.openspecindex.com/api/v1/parts?family=screw&attr.thread_spec=1/4-20&limit=15&offset=15"
for part in client.iter_parts(family="screw", thread_spec="1/4-20", max_parts=500):
    print(part.mpn, part.source_url)

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.

Reading the same record, field by field:

curl -s "https://api.openspecindex.com/api/v1/parts/bufab/104621020110" \
  | jq '.part | {mpn, source: .source.url, checked: .source.checkedAt, priceEach}'
part = client.part("bufab", "104621020110")

part.source.url          # the manufacturer page this record cites
part.source.checked_at   # when the record was last verified
part.price_each
part.spec("thread_spec")
part.assets_of("cadFiles")

Cite the source. A specification with no source is a rumour, and removing rumours is the entire point of the index.

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. The Python client keeps that message text verbatim.

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..." }
from openspec import BadRequest, NotFound

try:
    client.parts(family="tee", material_class="carbon-steel")
except BadRequest as e:
    print(e)
    # unknown value "carbon-steel" for attr.material_class in family "tee".
    # Valid values: pvc, cast-iron, stainless-steel, copper, steel, brass, ...

The status codes are 400 bad request, 404 not found, 429 rate limited, 5xx server error. In Python those are BadRequest, NotFound, RateLimited and ServerError, all subclasses of OpenSpecError.

understood.ignored_words on /find lists the words that matched nothing and were not applied as filters. Show them to your user. A filter silently dropped is worse than a filter refused, because the caller believes it was applied.

curl -s "https://api.openspecindex.com/api/v1/find/cheap-4-inch-grooved-carbon-steel-tee" \
  | jq '{total, family: .understood.family, ignored: .understood.ignored_words}'
# { "total": 9, "family": "tee", "ignored": ["cheap", "carbon"] }
result = client.find("cheap 4 inch grooved carbon steel tee")
result.total          # 9
result.family         # 'tee'
result.ignored_words  # ('cheap', 'carbon')

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.

A degraded response is a real answer to a question the API declined to fully answer, so read the count before you read the rows. In Python that is page.limited, which never raises; iterating a limited page raises RateLimited rather than yielding nothing, because looping over it as if it were zero results would turn "out of budget" into "no such parts exist".

curl -s "https://api.openspecindex.com/api/v1/parts?family=nut" | jq '{total, limited, rows: (.parts | length)}'
# { "total": 106102, "limited": true, "rows": 0 }
page = client.parts(family="nut")
if page.limited:
    print(f"{page.total} matches, but out of free row pulls")

THE PYTHON CLIENT

Installing it and proving it works is at the top of this page, under install and first run. This section is the library itself: what it hands back, what it does that a URL cannot, and how it fails. Passing a key is the same as anywhere else on this page, under passing a key.

THE OBJECTS

Responses come back as dataclasses, not dictionaries, so the field names are Python-cased rather than the JSON spelling.

part.mpn
part.manufacturer.name
part.part_type
part.source.url         # the manufacturer page this record cites
part.source.checked_at  # when the record was last verified
part.source_url         # shorthand for the same URL
part.price_each
part.spec("thread_spec")     # one value out of the source's own labels
part.fact("thread_spec")     # the normalised Fact, with its tier and source key
part.gates                   # the tier-1 facts: what defines identity
part.assets_of("cadFiles")
part.raw                     # the untouched JSON, always kept

Cite the source. A specification with no source is a rumour, and removing rumours is the entire point of the index.

PORTABLE ATTRIBUTE NAMES

Families spell the same concept differently. A tee has run_size, the reducer beside it has size_large_in, the elbow has size1_in. Portable names resolve to whichever the family uses, so one query shape covers many families.

for family in ("tee", "elbow", "reducer", "nipple", "coupling"):
    print(family, client.count(family=family, size_in=4, material_class="steel"))

client.category("reducer").resolve("size_in")   # 'size_large_in'

Available everywhere: size_in, size2_in, material_class, material_grade, finish, connection_type, connection_type2, schedule, thread, pressure_class, pressure_max_psi, temp_max_f, temp_min_f. The family's own names keep working.

COMPARING PARTS

compare() takes two to eight parts and answers both questions in one call: what they all state identically, and where they differ. It takes whatever you already have, so you can hand it the objects a search just returned.

a, b = client.part("bufab", "104621020110"), client.part("ace-hardware", "101134")

cmp = client.compare(a, b)                                          # Part objects
cmp = client.compare("bufab/104621020110", "ace-hardware/101134")   # or strings
cmp = client.compare(("bufab", "104621020110"), ("ace-hardware", "101134"))   # or tuples

print(cmp)                                  # 2 parts, 3 shared, 53 differing
for row in cmp.normalized_differences():
    print(row)                              # head style: pan | flat

Read row.source before calling a row a difference. A fact row is normalized: the attribute means the same thing whichever manufacturer published the part, which is the only reason parts from four suppliers compare at all. A spec row is one seller's own wording, where two catalogs differ without the parts differing. head style: pan | flat is a real difference; Drive Style: Phillips | [Phillips] Phillips is the same drive spelled twice. normalized_differences() returns only the first kind, and a None in values means that part does not publish it, which is a difference and never a zero.

price = cmp.supply_row("price_each")
price.values    # ('0.24', '11.49')
price.notes[1]  # 'the source states "Number in Package: 100 pack", so this price may be...'

Those two numbers are not a 48x difference: one of them buys a hundred screws. The API does not divide the pack out, because that would be inference rather than data, so read the note before comparing two prices. The other supply rows are lifecycle (None where the seller never stated one, which is not the same as active), cad, datasheets, images and source_url.

It reports, it does not judge. Nothing in a comparison says two parts interchange. That is a different claim and it is the one still waiting on match-quality validation.

PAGING

for part in client.iter_parts(family="screw", thread_spec="1/4-20", max_parts=500):
    ...

iter_parts() hides the offset arithmetic and stops on the first empty page, on max_parts, or when the free tier is exhausted. That last one matters: past the budget the answer will not change until the budget does, so it stops rather than retrying a request it already knows the shape of.

A LIMITED PAGE IS AN ANSWER, NOT A FAILURE

Past the free row budget the API returns a match count with no rows instead of refusing. The client surfaces that as page.limited rather than raising, because it is a real answer to a question the API declined to fully answer.

page = client.parts(family="nut")
if page.limited:
    print(f"{page.total} matches, but out of free row pulls")

Iterating a limited result raises RateLimited rather than yielding nothing. Looping over it as if it were zero results would turn "out of budget" into "no such parts exist", which is a different and much worse answer. .total, .limited and len() never raise, so you can always check first. See passing a key at the top of this page for how to lift the budget.

EXCEPTIONS

The API refuses on purpose where an empty result would be a wrong answer, and its messages carry the legal values. The client keeps that text verbatim rather than replacing it with one of its own.

from openspec import BadRequest

try:
    client.parts(family="tee", material_class="carbon-steel")
except BadRequest as e:
    print(e)
    # unknown value "carbon-steel" for attr.material_class in family "tee".
    # Valid values: pvc, cast-iron, stainless-steel, copper, steel, brass, ...

BadRequest (400), NotFound (404), RateLimited (429), ServerError (5xx), all subclasses of OpenSpecError. What each status actually means is under errors above.

NOT USING PYTHON?

Nothing above is required. The API is plain HTTP and JSON, and every example on this page has a curl form behind the EXAMPLES IN toggle at the top. Agents should start at llms.txt.

LIMITATIONS, STATED PLAINLY

  • 75 of ~890 families are verified. 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 is pinned down precisely; a part stating three generic ones pools broadly with its neighbours. Count the tier-1 facts on a record to see 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.

EVERY CATEGORY AND ITS GATES

Rendered live from /api/v1/categories, so it is never out of date with the database. Every category listed has been through spec verification. The GATES are the identity attributes that define the part (two parts must agree on every gate to be interchangeable); the rest are scored, filterable specs. It is a long list, which is the point: this is the whole queryable surface in one place. Last on the page on purpose, so it is something you scroll to rather than something you scroll past.

loading…

Machine-readable spec at openapi.json. Agent-oriented instructions at llms.txt. Python client details, install and paging behaviour are in the Python client section.