---
title: "Publish an API Catalog Agents Can Discover (RFC 9727)"
slug: publish-api-catalog-rfc-9727
published: 2026-09-25T08:23:19.86824+00:00
updated: 2026-09-25T08:23:19.86824+00:00
author: "Asif Rahman"
author_url: https://masifrahman.com
category: "AI Readiness"
tags: check:P1, check:E2, check:D3, API catalog, RFC 9727, linkset, agent discovery, OpenAPI, AI readiness
description: "RFC 9727: publish a linkset at /.well-known/api-catalog so AI agents can find your APIs. The mechanism, two paths, and a live adoption sweep across 32 hosts."
url: https://aiscan.site/blog/publish-api-catalog-rfc-9727
---

## Quick summary

Agents that want to call your API have to guess the URL today. [RFC 9727](https://www.rfc-editor.org/rfc/rfc9727.html) gives them a fixed place to look instead: `/.well-known/api-catalog`, a small JSON document that points straight at your OpenAPI spec and your docs. We probed 32 API-heavy domains on 25 September 2026 and found the file on 4, with a fifth serving the right JSON under the wrong content type, roughly one in six among sites that would benefit most.

| If you want to… | Do this | Check |
|---|---|---|
| Pass the fastest | Add one `Link` header plus a 15-line JSON file | `check:P1` |
| Point agents at an existing OpenAPI spec | Use the `service-desc` relation from [RFC 8631](https://www.rfc-editor.org/rfc/rfc8631.html) | `check:E2` |
| Cover several API domains from one place | Use `item` links plus nested `api-catalog` relations | RFC 9727 §5.1 |
| Confirm it actually works | `curl` a path that should 404, then compare | Landing check, below |

## Two files answer two different questions

[llms.txt](/llms-txt-generator) tells an agent what to *read*. `/.well-known/api-catalog` tells it what to *call*. A blog post, a pricing page, a docs article: those are llms.txt's job, and `check:C2` grades it. An endpoint that takes a request and returns structured data: that's what an API catalog exists for, and `check:P1` grades it. A site can have one without the other. A SaaS product with a public REST API and no blog needs the catalog and can skip llms.txt entirely; a documentation site with no API needs the reverse.

The gap the RFC closes is a genuine one. `robots.txt` says what a crawler may fetch. `sitemap.xml` says what pages exist. Neither says "here is a machine you can operate, and here is its instruction manual." An agent that wants to book a flight, check an order status, or pull a metric today has to already know your API's base URL, or has to have been told about it in a prompt, because there was never a well-known place to look for the answer. [RFC 9727](https://www.rfc-editor.org/rfc/rfc9727.html), published by the IETF in June 2025, fixes that the same way `robots.txt` fixed crawl discovery in 1994: one fixed path, resolved the same way everywhere.

## What the file actually has to contain

The RFC is short and the requirements are specific, so it's worth reading them straight rather than guessing:

- The document **MUST** be reachable at `/.well-known/api-catalog` and **MUST** be served as `application/linkset+json`, the format defined in [RFC 9264](https://www.rfc-editor.org/rfc/rfc9264.html). Other formats are allowed alongside it through content negotiation, never instead of it.
- The top-level key is `linkset`, an array of objects. Each object has an `anchor` (the resource the links describe) and one or more link-relation arrays hanging off it.
- Four relations, defined by [RFC 8631](https://www.rfc-editor.org/rfc/rfc8631.html), do the actual work: `service-desc` (a machine-readable spec, usually your OpenAPI document), `service-doc` (human docs), `service-meta` (policies, rate limits, anything else machine-readable), and `status` (a health or uptime endpoint). A simpler `item` relation, from [RFC 6573](https://www.rfc-editor.org/rfc/rfc6573.html), just lists API endpoints as bookmarks when you don't want to describe each one inline.
- The Linkset **SHOULD** carry a `profile` parameter pointing at `https://www.rfc-editor.org/info/rfc9727`, so a client can confirm the document is specifically an API catalog and not just any linkset.
- Serve it over HTTPS. The spec calls this out explicitly in its security section, alongside read-only access for external requests and a periodic prune of "zombie" APIs nobody maintains anymore.

That's the whole content requirement. What trips people up is everything the file is not: it is not your OpenAPI spec (that's what `service-desc` points to), not a sitemap, and not a replacement for API documentation. It is a signpost with exactly two required stops: what's here, and where the real description lives.

## Path one: point it at an OpenAPI spec you already publish

Most APIs worth cataloging already have an OpenAPI document sitting at `/openapi.json`. If that's you, this is a fifteen-minute change.

1. **Confirm your OpenAPI URL resolves.** `curl -s https://yourapi.com/openapi.json | head -c 200` should return JSON starting with `"openapi"` or `"swagger"`.
2. **Write the linkset**, using your API's base URL as the anchor:
   ```json
   {
     "linkset": [
       {
         "anchor": "https://api.yourcompany.com/",
         "service-desc": [
           { "href": "https://api.yourcompany.com/openapi.json", "type": "application/json" }
         ],
         "service-doc": [
           { "href": "https://docs.yourcompany.com/api", "type": "text/html" }
         ]
       }
     ]
   }
   ```
3. **Serve it at `/.well-known/api-catalog`** with `Content-Type: application/linkset+json`. On a static site, that's a file in `public/.well-known/api-catalog`; on a framework with typed routes, a route handler that sets the header explicitly, since most static-file servers default new extensions to `application/octet-stream` or refuse to serve dotfile directories at all.
4. **Add the `Link` header to your homepage response** so a crawler that never thinks to try the well-known path still finds it:
   ```
   Link: </.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json"
   ```
5. **Add the `profile` parameter** to the response's `Content-Type`: `application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"`.

## Path two: several API domains, one catalog

Bigger platforms split APIs across subdomains: a payments API at `api.example.com`, a webhooks service at `hooks.example.com`. RFC 9727 §5.1 covers exactly this with two extra relations: `item`, to bookmark each sub-API, and a nested `api-catalog` relation when a domain you don't own hosts part of your surface.

1. **Publish one catalog as canonical**, typically on your primary API domain, and make every other domain's `/.well-known/api-catalog` redirect to it rather than duplicating the content (the RFC's own recommendation), so the catalog never drifts out of sync with itself.
2. **List each sub-API as an `item`**, then give each its own anchor entry with its own `service-desc`:
   ```json
   {
     "linkset": [
       {
         "anchor": "https://www.example.com/.well-known/api-catalog",
         "item": [
           { "href": "https://api.example.com/v1" },
           { "href": "https://hooks.example.com/v1" }
         ]
       },
       {
         "anchor": "https://api.example.com/v1",
         "service-desc": [{ "href": "https://api.example.com/openapi.json", "type": "application/json" }]
       },
       {
         "anchor": "https://hooks.example.com/v1",
         "service-desc": [{ "href": "https://hooks.example.com/openapi.json", "type": "application/json" }]
       }
     ]
   }
   ```
3. **For a domain a partner controls**, don't inline their APIs. Link to their catalog with the `api-catalog` relation instead, so ownership stays where the RFC says it belongs.
4. **Group by category once the catalog gets large.** The spec's scalability guidance (§5.3) is to publish sub-catalogs per business or technology area and have the root catalog link to each, rather than shipping one enormous file.

## What to keep out of a public catalog

Not every API belongs in the public file. RFC 9727 splits this into two deployment shapes, and mixing them up is the most consequential mistake available here.

A **public catalog** is for APIs third parties should find and call. Before publishing, the spec's own security guidance (§8) says to review it for anything that leaks business or personal metadata, and to check that no internal-only API rode along by accident. A catalog hosted on your public developer domain should never expose paths that only make sense on your internal network, even if hand-writing the JSON made it tempting to just list everything you have.

Some organizations also want the well-known URI for **internal APIs** that employees or internal systems should discover, but that the public should never see. RFC 9727 §5.2 allows this, with the caveat that it carries its own risk: the well-known path is a predictable, guessable location, so an internal catalog needs real access control (CORS policy, an allowlist, authentication) in front of it, not just obscurity. Never serve a private catalog from the same public path a search engine or a curious agent might request.

Both shapes need the same ongoing care once live. The spec's monitoring guidance (§5.4) is specific: treat catalog upkeep as a release-lifecycle step, not a one-time publish. Remove an API from the catalog the same day you decommission it, since a stale entry that still resolves is what the RFC calls a "zombie API," undocumented, unpatched, and still reachable by anything that read the catalog before you took it down. TLS is a **SHOULD** for the whole file, not optional for the parts that feel sensitive. And keep write access to the catalog itself restricted to whichever role actually manages your API release process; a file anyone can edit is a file that drifts from what your APIs actually do.

## Verify it actually works

A status-code check alone proves nothing here, and we found that out the hard way on our own site. `curl -o /dev/null -w '%{http_code}' https://aiscan.site/.well-known/api-catalog` returns 200. So does `https://aiscan.site/.well-known/api-catalog-this-path-was-never-registered`, byte for byte, because our route matches on the path prefix rather than the exact suffix. A crawler that only checks for 200 would conclude the file exists at ten different URLs; only one of them is real. **Run the control probe before trusting the real one:**

| Step | Command | Pass signal |
|---|---|---|
| 1. Fetch the real path | `curl -sI https://yoursite.com/.well-known/api-catalog` | `200`, `Content-Type: application/linkset+json` |
| 2. Fetch a path that must not exist | `curl -sI https://yoursite.com/.well-known/api-catalog-xyz123` | `404` |
| 3. Compare | n/a | Step 1 succeeds, step 2 fails. If both return 200 with the same byte count, the route is a catch-all, not a real check |
| 4. Parse the body | `curl -s .../.well-known/api-catalog \| python3 -m json.tool` | Top-level key is `linkset`, not `links`, a common typo that breaks parsing silently since both look valid at a glance |
| 5. Confirm the link resolves | `curl -sI $(curl -s .../.well-known/api-catalog \| jq -r '.linkset[0]["service-desc"][0].href')` | `200` on the OpenAPI document itself |

We caught our own soft-200 with exactly this test while researching this post: `/.well-known/api-catalog`, `/.well-known/api-catalog/anything`, and `/.well-known/api-catalog2026` all return the identical 2,667-byte document instead of the last two 404ing. It's a routing-precision bug, not a missing file, and it's the kind a status-only scanner will never surface. We're disclosing it here rather than quietly patching it, because it's the cleanest live example of why step 2 above matters.

## What 32 API providers taught us

We probed 32 hosts on 25 September 2026, API-first platforms (Stripe, GitHub, Twilio, PayPal, Plaid, Slack, Auth0), a handful of infrastructure vendors, and our own domain, for `/.well-known/api-catalog`, running the control-path check above against every one.

| Result | Count | Hosts |
|---|---|---|
| Conformant (correct type + `profile` parameter) | 2 | `developers.cloudflare.com`, `vercel.com` |
| Adopted, correct media type, no `profile` parameter | 2 | `netlify.com`, `supabase.com` |
| Adopted, valid linkset body, wrong media type | 1 | `zoom.us` (`binary/octet-stream`) |
| Soft-200 false positive: passes a status-only check, fails the control probe | 2 | `platform.openai.com`, `api.slack.com` (both return the same SPA shell for the real path and a nonsense sibling path) |
| Hard 404 / 403 / 400 | 24 | Stripe, GitHub, Twilio, PayPal, Plaid, Slack, Auth0, OpenAI, and 16 others |

Five of 32 (15.6%) serve something real at the path; two of those five are fully spec-conformant. Two more sites would pass a naive `curl | grep 200` check and fail the moment you run the control probe from the verification section above, an external, independent confirmation of the exact failure mode we found on our own site. That lines up with the wider figure: according to Cloudflare Radar, which measured the 200,000 most visited domains for its April 2026 agent-readiness study, "MCP Server Cards and API Catalogs (RFC 9727) together appear on fewer than 15 sites in the entire dataset." This is a small, first-party sample of API-heavy domains, not a census. But the two companies that got it fully right, Cloudflare and Vercel, are also the two whose own developer platforms other companies build on, which is at least evidence this is a solvable fifteen-minute change rather than a genuinely hard problem.

## Where AIScan fits, and where it doesn't

[AIScan](https://aiscan.site/) grades this as `check:P1`, and the related `check:E2` (Machine-readable API description) checks whether `service-desc` actually resolves to something an OpenAPI parser accepts. `check:D3` (Link header for discovery) grades whether the homepage response advertises the catalog at all. Run `npx aiscan-cli yoursite.com`, or paste the URL at [aiscan.site](https://aiscan.site/), free, no account required, and all three come back in one pass.

What the scan can't tell you: whether the OpenAPI document your catalog points to is *accurate*. A `service-desc` link that resolves to a 200 with valid JSON passes P1 and E2 even if that spec describes endpoints you retired eight months ago. RFC 9727's own maintenance guidance (§5.4) is blunt about this: a Publisher should treat catalog upkeep as part of every API release, removing stale entries as part of the same change that ships the API update, because nothing external checks that for you. AIScan can confirm the door exists and opens; it can't confirm what's behind it still matches the label.

If your catalog needs to describe an MCP server rather than a REST API, that's a different file with its own check: see [Publish an MCP Server Card So Agents Can Find Your Tools](/blog/publish-mcp-server-card) for the `check:P2`–`check:P4` mechanism. The two aren't interchangeable: a server card answers "what tools can an agent call through MCP," while an API catalog answers "what HTTP APIs does this domain expose." A platform with both should publish both, cross-linked from each other's `service-meta`.

Full check definitions live on [`/docs/checks/capabilities`](/docs/checks/capabilities); more on the wider discovery-file picture is at [`/guides`](/guides).

## Next step

Run `npx aiscan-cli yoursite.com`, or scan at [aiscan.site](https://aiscan.site/), for `check:P1`, `check:E2`, and `check:D3` together. If you're weighing this against an MCP server card, read [Publish an MCP Server Card So Agents Can Find Your Tools](/blog/publish-mcp-server-card) next, and see the full rubric at [`/docs/checks/capabilities`](/docs/checks/capabilities) or browse every guide at [`/guides`](/guides).

