GolfAPI Driver Catalog — API Reference
Unlisted API reference. Current behavior version: 2026-09-24. Club documents reuse the published 2026-09-15 representation. Check /coverage for the live build and counts.
This page contains no credentials. An API key is supplied separately by whoever gave you this link. /, /openapi.json, /.well-known/openapi.json, /docs, and /health are public; catalog and coverage routes require a bearer key.
A key is issued as two values, and only one of them authenticates. If you were handed a pair, send the second kind. These two names are canonical — store each value under the name in the first column and every example on this page will work as written:
| Store it as | Shape | Use |
|---|---|---|
GOLFDEV_API_KEY_ID | key_ + 26 uppercase alphanumerics, e.g. key_01EXAMPLE00000000000000000 | Not a credential. It names the key for revocation and support. Sending it as a bearer token always returns 401. |
GOLFDEV_API_KEY | sk_live_ or sk_test_ + 32 lowercase hex, e.g. sk_live_ followed by 9f2c4a1e… out to 32 characters | The credential. This is the value that goes in the Authorization header. It is shown once at issue and stored only as a hash. |
sk_live_ reads production; sk_test_ reads a fixed sandbox fixture. The mode prefix is part of the secret, not a separate parameter.
What this API is
A catalog of golf drivers from major manufacturers. It exists to answer two questions: which club is this, and what exact build did the manufacturer offer.
It is deliberately positioned for programmatic consumers, including AI agents, rather than for a human browsing a website.
Product tiers
| Tier | Status | What it adds |
|---|---|---|
| Basic | Available to catalog keys | Club identity and core specs: name, manufacturer, family, lofts, hands, head volume, adjustability flags, release date, and intro price where published. |
| Club-builder | Gated, selected keys | Exact-build depth: variants, hosel systems and settings, stock shafts, grips, weights, head geometry, compatibility. |
| Full | Not built | Complete catalog plus insight fields, undefined pending customer demand. |
The omission rule
This is the central data contract and it matters when you evaluate the responses. An absent field means no value was published for that record. It may still be awaiting curation; it is not a verified negative. A value only appears after a human has signed off on it. Nulls and placeholders are not used, so you will see objects with fields simply missing rather than set to null.
A real 0 or false is always returned, never dropped. "adjustable_weight": false means verified as not adjustable.
What is in the published catalog
GET /coverage is the live census for the key you send. It names the published build, publication time, club types, manufacturer-by-year counts, and per-field presence. Read it before presenting a search miss as proof that a club does not exist. A year missing from the grid means no record is published for that manufacturer and year; it does not establish that the manufacturer released nothing.
curl -sS https://clubapi.golfapi.dev/coverage \
-H "Authorization: Bearer $GOLFDEV_API_KEY"
For a driver list filtered by profile, inspect page.profile_coverage. Its counts cover published drivers for the requested manufacturer, or all published drivers when no manufacturer was requested. Other filters do not narrow that denominator. At the 2026-09-24 pin, all_published_records_classified says only that those published records have profiles; catalog_complete: false says the eligible lineup is not certified complete. An empty result cannot establish that a manufacturer made no driver with that profile. Older pins call the first flag complete; it has the same limited meaning.
intro_price is a historical launch MSRP when present. A missing price or profile is an unpublished value, not a verified negative. A real false or 0 is always retained. The current release offers driver identification and core specifications; detailed hosel charts and equipment are separate gated releases.
The launch target is every qualifying US retail driver head from the 11 named manufacturers between 2020-01-01 and 2026-09-15. The published grid is the current subset, not a completion claim. Mini-drivers are a separate resource and have no profile filter.
That target is machine-readable. GET /coverage carries catalog_complete: false and, on the driver entry, a scope naming those 11 manufacturers and the two dates. Read it before treating any count here as a denominator. What the census can establish is what this build published; what it cannot establish is why a head is absent — it counts published records, so a head that was never released and a head not yet ingested are the same zero. A search miss, an empty page and a 404 are all statements about the published build.
Two notes for anyone syncing
updated_at is a record modification timestamp, not a build timestamp. Use it for incremental syncs within one representation shape. When you change the Golfapi-Version pin, perform a full resync; a version change does not guarantee every record's timestamp changes.
Connecting
| Base URL | https://clubapi.golfapi.dev |
|---|---|
| Auth | Authorization: Bearer $GOLFDEV_API_KEY, header only, never a query parameter |
| Secret format | sk_live_ or sk_test_ followed by 32 lowercase hex characters. The key_… id (GOLFDEV_API_KEY_ID) is not the secret. |
| Format | JSON. No request body on any endpoint; everything is query parameters. |
curl -H "Authorization: Bearer $GOLFDEV_API_KEY" \
"https://clubapi.golfapi.dev/drivers?manufacturer=ping&limit=2"
The contract is machine-readable. GET /openapi.json is an OpenAPI 3.1 document covering every route, parameter, response shape and error; GET /docs renders it. Both are unauthenticated. Generate a client from the document rather than transcribing this page: npx openapi-typescript https://clubapi.golfapi.dev/openapi.json -o golfapi.d.ts.
Discovery is one document. GET / is unauthenticated and returns a JSON index: every routed resource with its path, entitlement, supported serving shape, and published flag. A supported route can still be unpublished and answer 503 to an entitled key. Sub-resources are listed with their own paths and entitlements.
/.well-known/openapi.json is an alias for the same machine-readable document. This page is a summary; where it disagrees with the served document, use the served document.
Routes
| Route | Auth | Expected |
|---|---|---|
GET / | none | 200 resource index |
GET /health | none | 200 {"ok":true,"db":"ok","request_id":"req_…"}. degraded lists any shape whose columns exist but which no build filled. It is an operator signal: a pin at such a shape resolves down to the shape below it and answers 200, it never costs you a 503 |
GET /openapi.json or GET /.well-known/openapi.json | none | 200 the OpenAPI 3.1 document for every route |
GET /docs | none | 200 that document, rendered |
GET /coverage | required | 200 the catalog census: manufacturer by model year, plus per-field presence counts, for every club type the key is entitled to. Any club entitlement reaches it; Club-builder is not required. Takes no parameters |
GET /drivers | required | 200 list |
GET /drivers/{slug|id} | required | 200 single object |
GET /mini-drivers | required | 200 list |
GET /mini-drivers/{slug|id} | required | 200 single object |
GET /hosel-systems | required | 403 without Club-builder entitlement; 503 with it — routed, hosel catalog unsigned |
GET /hosel-systems/{id} | required | 403 without Club-builder; 503 with it, same reason |
GET /hosel-systems/{id}/clubs | required | 403 without Club-builder; 503 with it, same reason |
GET /drivers/{ref}/hosel | required | The club's hosel context, narrowable by hand and market. 403 without Club-builder, which is what an evaluation key receives: entitlement is checked before publication state, so you never see the 503 underneath it. With Club-builder it is 503 today, because the signed hosel package gates it. That package gates nothing else — a club document is served whether or not it exists. |
GET /mini-drivers/{ref}/hosel | required | as above for mini-drivers |
GET /weight-systems | required | 403 without the weight-systems entitlement; 503 with it — routed, catalog unpublished |
GET /shafts | required | 403 without the shafts entitlement; 503 with it — routed, catalog unpublished |
GET /grips | required | 403 without the grips entitlement; 503 with it — routed, catalog unpublished |
GET /weight-systems/{id}, /shafts/{id}, /grips/{id} | required | detail routes, same entitlement and same pre-release 503 |
The equipment routes are entitlement-gated like every other resource. No key issued for evaluation carries them, so what you will see is 403, not 503. A key that does carry the entitlement gets 503 until the catalog is published: that is the designed pre-release state, not an outage. Branch on the status you actually receive — 403 means buy the entitlement, 503 means retry later.
/health and what it means
/health is unauthenticated and is not a liveness check for the whole service. It answers 503 only when nothing can be served: the database is unreachable or unbound, a registered club type's table is missing, or the live build holds no document for the default shape.
A shape whose columns were migrated but which no build ever materialized is reported as degraded at 200. Do not page on it, and do not read it as a warning to clients: catalog resolution skips an unmaterialized shape, so a pin at one resolves down to the shape below it and is answered 200 naming that shape in Golfapi-Version. Nothing in degraded produces a 503. Until 2026-09-22 a 2026-09-14 pin on /drivers did answer 503, with Retry-After: 3600 on a shape no retry would ever produce; that is fixed.
/health is read from the live build at request time and has been observed answering degraded for a few minutes after a publish that cleared it. Re-probe before acting on it.
GET /health
{ "ok": true, "db": "ok", "request_id": "req_…" }
The absence of degraded is the machine-readable answer to "did the live build materialize every shape its tables carry". GET / answers the client-facing question separately: version.serving at the top, and a serving date on each entry of resources[]. Nothing automated compares the two; this page previously claimed they were checked against each other on every release, and they were not.
Query parameters
List routes
| Parameter | Accepts | Notes |
|---|---|---|
limit | 1–100 | Default 20. Out of range is 400. |
cursor | opaque string | Use page.next from the previous response unchanged. Edited cursors are 400. |
view | basic | expanded | Default basic. Asking for expanded without the Club-builder entitlement is 403, not a thinned document. |
fields | comma-separated | Restricts returned fields. An unknown name for the active view is 400. id is always returned, whether or not you name it, so a projection is always joinable back to a record. |
manufacturer | slug | At 2026-09-24, a slug absent from the requested club type is 400 with values that collection offers. Older pins check across club types. Combinations of individually valid filters with no record still return 200 with data: []. |
family | slug | e.g. g440. The same collection-scoped membership rule applies at 2026-09-24. |
profile | draw | neutral | low-spin | Drivers only. Not a parameter on /mini-drivers. |
hand | rh | lh | |
loft | degrees, as a string | A query string, matched as written: one or two digits with at most one decimal place. 10.5 and 10 are accepted; 10.50 and 10.25 are 400. It is not parsed as a number first, so trailing-zero forms are not equivalent. Membership-validated like manufacturer, and validated against the collection in the path: a loft that club type does not offer is 400 naming the lofts it does, not a 200 with an empty page, so a dropped decimal point (105 for 10.5) is told rather than silently answered. Today /drivers offers 8–12.5 in half-degree steps and /mini-drivers offers 11.5, 13, 13.5. /drivers?loft=13 is therefore a 400, not an empty page: 13° is a mini-driver loft and no driver carries it. Until 2026-09-21 it answered 200 [], because the offered set was taken across every club type. |
adjustable_hosel | true | false | Literal strings only. |
adjustable_weight | true | false | Literal strings only. |
weight_system | fixed | movable | interchangeable | reversible | reversible exists only from version 2026-09-15, which is the shape the build now serves and the default in openapi.json, so it works out of the box. A request pinned below 2026-09-15 is 400, and the message tells you to send the header. Two Wilson heads are the only reversible records, and they are invisible at earlier shapes rather than relabelled. |
released_after | ISO date | YYYY-MM-DD |
released_before | ISO date | YYYY-MM-DD |
q | 1–200 chars | Free-text discovery. At 2026-09-24, exact and full-text matches appear in data; a miss may include up to five labeled page.search_suggestions. Suggestions are possible corrections, never identified clubs. Fetch a chosen id before using it. Control characters are rejected. |
market | a market the build prices | Shapes intro_price, never which clubs a page holds. Only US carries verified pricing today, and any other value is 400 naming the markets that exist — malformed or well-formed alike. It is checked against the published build rather than against ISO on purpose: no match would have omitted intro_price, and an absence here means "not verified", so a typo would have been indistinguishable from unpriced data. A match never returns an empty array. |
hosel_system | hos_ + 26 chars | Filter by hosel system id. The 26 characters are Crockford base32: digits and uppercase letters excluding I, L, O and U. Club-builder only, and the entitlement is checked before the value is parsed: a Basic key sending a malformed hosel_system gets 403, not 400. That is deliberate — the error class turns on the entitlement, never on the value, so a 400 here would teach the id format to a caller who cannot use the parameter. |
Detail routes
| Parameter | Accepts | Notes |
|---|---|---|
view | basic | expanded | At the current club shape, omitting view serves the normal/basic document for every key. Request view=expanded with Club-builder access; without it the response is 403. Older club shapes may default differently. Read Golfapi-View. |
fields | comma-separated top-level names | Validated against the active view. Nested names such as hosel_summary.system_name in /coverage are counts, not projection selectors. |
variants | all | rh | lh | loft | Default all. One selector, never a pair. Both hands is the default and is spelled all — variants=both is 400. Shapes the expanded document only; on a basic response it changes neither the body nor the ETag. |
market | a market the build prices | As on the list routes — an unpriced market is 400, not a 200 with the price dropped — and it moves the ETag: the detail tag carries an .m<code> representation suffix. |
Response shapes
List — wrapped
GET /drivers?limit=1&manufacturer=ping
{
"data": [
{
"id": "clb_01M21MDKGYVNWAT3EFJ9XCE0PC",
"slug": "g440-k-driver",
"name": "G440 K Driver",
"manufacturer": { "slug": "ping", "name": "PING" },
"family": { "slug": "g440", "name": "G440" },
"profile": "neutral",
"released": "2026-01-29",
"lofts_deg": [9, 10.5, 12],
"hands": ["rh", "lh"],
"volume_cc": 460,
"adjustable_hosel": true,
"adjustable_weight": true,
"weight_system": "movable",
"intro_price": [
{ "market": "US", "msrp_minor": 70500, "currency": "USD" }
],
"url": "https://clubapi.golfapi.dev/drivers/g440-k-driver",
"hosel_summary": { "adjustable": true },
"updated_at": "2026-09-21T20:42:39Z"
}
],
"page": { "limit": 1, "has_more": true, "next": "cur_eyJyIjoi..." }
}
Prices are minor units. 70500 with "currency": "USD" is $705.00. The last two fields arrived with the 2026-09-15 shape; a request pinned below it receives the same record without them.
The price is the figure the manufacturer stated, not a normalized one. Nothing is rounded and no cents are invented, so both encodings you will see are exact: a head announced at $649.99 carries 64999 and one announced at $649 carries 64900. Both whole-dollar and 99-cent prices occur — Qi4D at 64999, the Titleist GT heads at 64900, G440 K at 70500. A trailing 00 is a whole-dollar launch price, never a truncated one.
Lists are ordered. Every list route returns released descending with the
clb_ id descending as the tiebreak, and there is no sort parameter. ?q= is the one exception:
it orders by relevance first and keeps the same tiebreak under it. Pagination is keyset over that order, so
a cursor is only valid for the filter set that produced it.
Only id is required in the OpenAPI schema, and that is about
fields=, not about the data. Projection can narrow a response to any subset, so a
literal required list would be wrong for every projected response. The unprojected set is
published instead as x-required-unprojected on each schema — 13 keys on the basic club
document, 15 at 2026-09-15, 21 expanded. Code generators ignore vendor extensions, so a
generated client will null-check name, slug, manufacturer and
released; none of those has ever been absent from a published record. The two fields that
genuinely can be absent are profile and intro_price, and the table above says
where.
Detail — not wrapped
GET /drivers/g440-k-driver
{
"id": "clb_01M21MDKGYVNWAT3EFJ9XCE0PC",
"slug": "g440-k-driver",
...
"url": "https://clubapi.golfapi.dev/drivers/g440-k-driver"
}
The detail route returns the object at the top level. It is not wrapped in data the way list responses are. Clients must handle both shapes.
Basic vs Club-builder depth
The same club, fetched with two different keys:
| Key | Fields returned |
|---|---|
| Basic | id, slug, name, manufacturer, family, profile, released, lofts_deg, hands, volume_cc, adjustable_hosel, adjustable_weight, weight_system, intro_price, url, plus hosel_summary and updated_at at 2026-09-15 and above. updated_at is the only modification timestamp on the resource and the field an incremental sync is built on; it is on every published record at that shape. hosel_summary carries adjustable always, and system_name / loft_adjustment_deg where they were supplied. loft_adjustment_deg is defined by the schema and unpublished at this version: no record carries it today, because materialization never infers an offset from a detailed hosel chart and no signed input had supplied one at this reference update. Read the current zero or positive count in /coverage; code for its absence; do not read the absence as "this hosel does not adjust loft". |
| Club-builder | the above plus head, hosel, notes, _links, variant detail |
Errors
Every error uses the same envelope, with a request_id and an optional param naming the offending input.
{
"error": {
"type": "invalid_request",
"message": "Invalid value for 'limit': must be between 1 and 100.",
"status": 400,
"request_id": "req_01M2R68V6A5K9J0A5HQWJB2N2P",
"param": "limit"
}
}
| Status | When |
|---|---|
400 | Unknown fields name, unknown parameter, a parameter sent twice, limit out of range, malformed or edited cursor, invalid enum or date, a manufacturer, family or loft no published club in this collection carries at the 2026-09-24 pin, a market the build does not price, and released_after later than released_before. The rule: an unknown value for a single membership-validated parameter (manufacturer, family, loft, market) is a 400 naming the published legal values, while legal values AND-combined into a combination nothing satisfies is 200 with "data": []. manufacturer=ping&profile=draw is an empty page, not an error. |
401 | Missing, wrong-scheme, malformed, or unaccepted key. These are diagnostics, not machine subtypes: error.type is invalid_auth for each, and it is the value to branch on. A valid key sent with no Bearer in front of it is told the scheme is missing rather than that the key is malformed. "Malformed API key…" means the value is not a secret at all; the key_… id gets its own message naming what to send instead. |
403 | Known key without entitlement to the resource or to Club-builder depth. |
404 | Single fetch of an unknown record. An empty list is 200 with "data": [], never 404. A slug or clb_ id that is live under a different club type is still a 404 on this route — club types are separate collections and are never redirected between — and the message names the other route when your key may read it. A name this club type published and then withdrew is a 404 too, never a 301. |
405 | A routed path reached by a method other than GET or OPTIONS. Carries Allow. |
429 | Over the rate limit. Retry-After equals RateLimit-Reset. |
500 | Server fault. The message never carries the internal error; retry with backoff. |
503 | Entitled but unpublished catalog, or a version pin whose shape the published build does not hold. The message names the shape and sets "param": "Golfapi-Version" when you sent the header. |
Observed error messages: "Unknown field 'nope' for view 'basic'." · "Invalid cursor. Use the 'next' value from a previous page unchanged." · "No driver with slug 'no-such-club'."
Versioning, caching, redirects
- Pin
Golfapi-Version: 2026-09-24to get the current list behavior. Existing keys keep their assigned pin unless they send this header. The2026-09-24club document uses the published2026-09-15representation; this release changes list search, filter validation, and coverage metadata. - The response's
Golfapi-Versionnames the shape served. A requested date between releases, or later than the newest release, resolves down to the newest available shape. A pin below2026-09-08or an impossible date is400. The2026-09-14club document was never populated, so a pin there resolves to2026-09-08. BasicClubin OpenAPI names the2026-09-08version, whileNormalClubnames the2026-09-15document used by the current version.view=basic|expandedis a separate depth choice. Follow the response schema'sanyOf. OpenAPIinfo.versionis a release number, not aGolfapi-Versiondate.- A legacy pin may serve a smaller catalog: heads with
weight_system: reversibleare excluded from the2026-09-08shape because it cannot name that mechanism./coveragereports the count at the served shape. - Responses carry an
ETag. A matchingIf-None-Matchreturns304, and so doesIf-None-Match: *. A list tag is a SHA-256 of the response body. A detail tag is opaque and its shape depends on the version you are served. At2026-09-15a plainGET /drivers/g440-k-drivercarries"driver-20260915-normal-<sha256>~20260915~vb"; at2026-09-08the same request carries"clb_<id>-<epoch>~vb". Three parts, and none of them is a contract: a record tag, then~and the shape date with its dashes removed on any shape from2026-09-14up, then~and an encoding of the representation when the request shaped it (view,fields,variants,market) —~vb,~vb.mUS. This page described the record tag as"{id}-{modified epoch}"and named neither of the other two parts until 2026-09-21, so a client written against it and pinned to the current version saw a tag it could not parse. Do not parse any of it: send the whole quoted string back inIf-None-Matchand nothing else. Cloudflare's edge marks the tag weak (W/"…") when it compresses the response; the API accepts either form. - A
401carriesWWW-Authenticate: Bearerwhen the Authorization header is absent andBearer error="invalid_token"when a credential was sent. It carries noRateLimit-*headers: the limit belongs to a key, and an unaccepted request has none. - A changed slug returns
301to the current path. Anidnever redirects — ids are stable, slugs are not. Test it against/drivers/g440-max-2025-driver, which redirects to/drivers/g440-max-driver. - A
clb_id is case-insensitive in its 26-character suffix, because Crockford base32 is.clb_01j8…andclb_01J8…are one id: the same document, the sameETag, no redirect. Slugs are genuinely case-sensitive and stay lowercase;/drivers/G440-MAX-DRIVERis a404whose message names the lowercase form. Golfapi-Viewnames the depth of a document: it is meaningful on a catalog response that serves or revalidates one (200,304), and on anything else it carries no contract, because an error carries no document. Branch onerror.typeand the status, not on this header. The two ways of reaching a depth differ:view=expandedasked for by name without the capability is403, while an omitted detail view at the current shape servesbasicand answers200. Read the header instead of inferring depth from what you sent.- A path with a trailing slash is normalized:
GET /drivers/is301to/drivers, query string preserved, never a404. It resolves before authentication, so it costs the key nothing. - Every response carries
Golfapi-Request-Id, the samereq_…value an error body carries. Quote it in a report about a200or a304. - A
304is refunded and does not count against the rate limit. ItsRateLimit-Remainingreports the budget after that refund, so a revalidating client sees a counter that holds still because it is not spending, not because it is stuck. - A
503from a version pin is not a blip: it clears when an operator publishes a build, which can take days.Retry-Afteron it is an hour-long hint, not a promise — cap your own backoff and poll/health, which names the shapes the live build is missing. - Rate limit: 60 requests per 60-second window, aligned to the wall-clock minute.
RateLimit-*headers appear only once a key has been accepted and the request has entered an authenticated resource — a401, a405,/healthand/carry none, because the limit belongs to a key and those responses have not got one. The counter is per key, not per client: if two processes share a key,RateLimit-Remainingmoves without either one seeing the other's traffic. Use a separate key per evaluation when you need an isolated observation.Retry-Afterappears on429and on503, and nowhere else. CheckRateLimit-Limit,RateLimit-RemainingandRateLimit-Reset(seconds left in the window, never0). Over the cap is429withRetry-Afterequal toRateLimit-Reset. HTTP header names are case-insensitive, but these are the names the API sends; this page lowercased them until 2026-09-22.GET /carries the same two numbers underrate_limit, so you need not spend a request to learn them.
Known gaps
Stated plainly so evaluation focuses on what matters rather than rediscovering these:
profileandintro_priceare incomplete. Read the current counts in/coverage. Every profile-filtered driver page reports its published-record denominator.catalog_complete: falsemeans evenunclassified: 0does not prove the eligible lineup is complete.marketis effectively a constant:USis the only market the build prices, and any other well-formed code is400. Do not build a currency abstraction against it yet./shafts,/grips,/weight-systemseach need an explicit equipment grant. They are unpublished: an entitled key receives503withRetry-After. A403proves only that this key lacks that resource grant./hosel-systemsandview=expandedrequire the Club-builder entitlement. Expanded club documents are available to entitled keys; the separate hosel-system catalog is unpublished and returns503to an entitled key.- List responses are wrapped in
datawith apageobject beside it; a detail response is the bare club document. They are different envelopes, so a client cannot share one parser across the two routes. - An
ETagbelongs to a URL, not to a record.market=USchanges no bytes today —USis the only priced market — but it is part of the representation, so it still moves the detail tag (the~…mUSsuffix). Store the whole quoted tag against the exact URL you fetched, and do not carry one between two spellings of the same request. 429and itsRetry-Afterare documented but rarely exercised; the limit is 60 per wall-clock minute.
Testing etiquette
This is a live production service on a small budget. Probe correctness and edge cases freely; keep total requests modest and do not load-test, benchmark throughput, or run concurrent floods. Rate limiting will stop you at 60 per 60-second window regardless.