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.
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)
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 see | What it means, and the fix |
|---|---|
externally-managed-environment | Your 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 openspec | It 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 pip | Rare, and means the venv was built without pip. Delete the folder and redo step 2, or add --upgrade-deps. |
RateLimited | Not an error in your code. The free row budget for your IP is spent. Counts still work; see above. |
BadRequest with a list of values | A 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 denied | You 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
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.
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 question | Call this | You 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
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
| 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/compare | Two 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.
| Parameter | Meaning |
|---|---|
family | Verified 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. |
Paging is the same idea either way: ask for a page, then ask for the next one.
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.
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.
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.
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.
Machine-readable spec at openapi.json. Agent-oriented instructions at llms.txt.