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 asShapeUse
GOLFDEV_API_KEY_IDkey_ + 26 uppercase alphanumerics, e.g. key_01EXAMPLE00000000000000000Not a credential. It names the key for revocation and support. Sending it as a bearer token always returns 401.
GOLFDEV_API_KEYsk_live_ or sk_test_ + 32 lowercase hex, e.g. sk_live_ followed by 9f2c4a1e… out to 32 charactersThe 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

TierStatusWhat it adds
BasicAvailable to catalog keysClub identity and core specs: name, manufacturer, family, lofts, hands, head volume, adjustability flags, release date, and intro price where published.
Club-builderGated, selected keysExact-build depth: variants, hosel systems and settings, stock shafts, grips, weights, head geometry, compatibility.
FullNot builtComplete 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 URLhttps://clubapi.golfapi.dev
AuthAuthorization: Bearer $GOLFDEV_API_KEY, header only, never a query parameter
Secret formatsk_live_ or sk_test_ followed by 32 lowercase hex characters. The key_… id (GOLFDEV_API_KEY_ID) is not the secret.
FormatJSON. 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

RouteAuthExpected
GET /none200 resource index
GET /healthnone200 {"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.jsonnone200 the OpenAPI 3.1 document for every route
GET /docsnone200 that document, rendered
GET /coveragerequired200 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 /driversrequired200 list
GET /drivers/{slug|id}required200 single object
GET /mini-driversrequired200 list
GET /mini-drivers/{slug|id}required200 single object
GET /hosel-systemsrequired403 without Club-builder entitlement; 503 with it — routed, hosel catalog unsigned
GET /hosel-systems/{id}required403 without Club-builder; 503 with it, same reason
GET /hosel-systems/{id}/clubsrequired403 without Club-builder; 503 with it, same reason
GET /drivers/{ref}/hoselrequiredThe 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}/hoselrequiredas above for mini-drivers
GET /weight-systemsrequired403 without the weight-systems entitlement; 503 with it — routed, catalog unpublished
GET /shaftsrequired403 without the shafts entitlement; 503 with it — routed, catalog unpublished
GET /gripsrequired403 without the grips entitlement; 503 with it — routed, catalog unpublished
GET /weight-systems/{id}, /shafts/{id}, /grips/{id}requireddetail 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

ParameterAcceptsNotes
limit1–100Default 20. Out of range is 400.
cursoropaque stringUse page.next from the previous response unchanged. Edited cursors are 400.
viewbasic | expandedDefault basic. Asking for expanded without the Club-builder entitlement is 403, not a thinned document.
fieldscomma-separatedRestricts 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.
manufacturerslugAt 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: [].
familysluge.g. g440. The same collection-scoped membership rule applies at 2026-09-24.
profiledraw | neutral | low-spinDrivers only. Not a parameter on /mini-drivers.
handrh | lh
loftdegrees, as a stringA 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_hoseltrue | falseLiteral strings only.
adjustable_weighttrue | falseLiteral strings only.
weight_systemfixed | movable | interchangeable | reversiblereversible 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_afterISO dateYYYY-MM-DD
released_beforeISO dateYYYY-MM-DD
q1–200 charsFree-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.
marketa market the build pricesShapes 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_systemhos_ + 26 charsFilter 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

ParameterAcceptsNotes
viewbasic | expandedAt 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.
fieldscomma-separated top-level namesValidated against the active view. Nested names such as hosel_summary.system_name in /coverage are counts, not projection selectors.
variantsall | rh | lh | loftDefault 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.
marketa market the build pricesAs 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:

KeyFields returned
Basicid, 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-builderthe 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"
  }
}
StatusWhen
400Unknown 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.
401Missing, 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.
403Known key without entitlement to the resource or to Club-builder depth.
404Single 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.
405A routed path reached by a method other than GET or OPTIONS. Carries Allow.
429Over the rate limit. Retry-After equals RateLimit-Reset.
500Server fault. The message never carries the internal error; retry with backoff.
503Entitled 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

Known gaps

Stated plainly so evaluation focuses on what matters rather than rediscovering these:

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.