Publishing OpenAPI Specs to the Service Catalog

Registering an API as a first-class catalog entity is what turns a buried openapi.yaml into discoverable, owned, interactively-rendered documentation. This how-to covers the API entity kind, the $text placeholder that inlines a spec at ingestion, and the relations that link an API to the service that provides it. It implements the contract-publishing step of Internal API Portals & Discovery within Developer Experience & Self-Service Platforms.

Publishing a spec needs four things: catalog ingestion for your VCS, the api-docs plugin to render, an OpenAPI document in the repo, and a resolvable Group owner that already exists in the catalog.

Prerequisites for publishing an OpenAPI spec Catalog ingestion, the api-docs plugin, an OpenAPI document, and a resolvable owner make a spec publishable. catalog ingestion api-docs plugin OpenAPI 3.x doc resolvable owner Publishable rendered + owned
The owning Group must be ingested first — ordering matters on a cold catalog, or the API shows an unknown owner.
  • Backstage >= 1.20.0 with catalog ingestion configured for your VCS provider.
  • @backstage/plugin-api-docs >= 0.11.0 registered in the app for interactive rendering.
  • An OpenAPI 3.0 or 3.1 document checked into the service repository.
  • A resolvable owner — the Group entity must already exist in the catalog.
  • Catalog rules that allow the API kind (see step 4).

Exact Configuration

Publishing is four steps: declare the API entity with a $text spec reference, link the providing component with providesApis, validate both in CI, and allow the API kind in ingestion rules. The diagram sequences them.

Four steps to publish an API entity Declare the API entity, link the providing component, validate in CI, and allow the API kind in ingestion. API entity $text spec ref Link component providesApis CI validate lint + catalog Allow API kind ingestion rules
The $text reference inlines the spec at ingestion, so the contract stays versioned with the code that implements it.

1. Add the API entity to catalog-info.yaml

# catalog-info.yaml
# Requires Backstage >= 1.20.0
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
  name: payments-api
  description: Synchronous payment authorization API
  tags: [rest, payments]
spec:
  type: openapi
  lifecycle: production
  owner: group:payments-platform
  system: payments
  definition:
    $text: ./openapi.yaml

The $text placeholder tells the catalog to read the referenced file and inline its contents into spec.definition at ingestion time. The path is resolved relative to catalog-info.yaml, so co-locate the spec in the same repository.

# catalog-info.yaml (Component for the same service)
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payments-service
spec:
  type: service
  lifecycle: production
  owner: group:payments-platform
  providesApis:
    - payments-api
  consumesApis:
    - fraud-check-api

providesApis and consumesApis create bidirectional relations, so the API page lists its providers and the component page lists its interfaces. These relations follow the same modeling discipline as your catalog integration patterns.

3. Validate the spec in CI before merge

# .github/workflows/api-validate.yml
# Requires Backstage >= 1.20.0
name: Validate API Entity
on:
  pull_request:
    paths: ['**/openapi.yaml', '**/catalog-info.yaml']
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint the OpenAPI document
        run: npx @redocly/cli@^1.0.0 lint openapi.yaml
      - name: Validate the catalog entity
        env:
          CATALOG_SERVICE_TOKEN: ${{ secrets.CATALOG_SERVICE_TOKEN }}
        run: npx @backstage/cli@^0.27.0 catalog validate --path ./catalog-info.yaml

4. Allow the API kind in ingestion rules

# app-config.yaml
# Requires Backstage >= 1.20.0
catalog:
  rules:
    - allow: [Component, API, System, Group, Resource]
  locations:
    - type: url
      target: https://github.com/${GITHUB_ORG}/payments-service/blob/main/catalog-info.yaml

Confirm three things: the API entity inlined its definition (not a path), the providesApi relation resolved on the component, and local validation passes before you push.

Three OpenAPI-publishing validation checks The definition inlined, the provides relation resolved, and local validation passes. Definition inlined length > 100 Relation resolved providesApi Local validate 2 entities, 0 errors
If spec.definition is still a path, the $text reference failed to resolve — check the file is reachable at ingestion.
# Requires Backstage >= 1.20.0
# 1. The API entity exists and inlined its definition
curl -s "${CATALOG_BACKEND_URL}/api/catalog/entities/by-name/api/default/payments-api" \
  -H "Authorization: Bearer ${CATALOG_SERVICE_TOKEN}" \
  | jq '.spec.definition | length > 100'
# Expected: true (the spec text is inlined, not a path)

# 2. The provides relation resolved on the component
curl -s "${CATALOG_BACKEND_URL}/api/catalog/entities/by-name/component/default/payments-service" \
  -H "Authorization: Bearer ${CATALOG_SERVICE_TOKEN}" \
  | jq '[.relations[] | select(.type=="providesApi") | .targetRef]'
# Expected: ["api:default/payments-api"]

# 3. Local validation passes before pushing
npx @backstage/cli@^0.27.0 catalog validate --path ./catalog-info.yaml
# Expected: "Validated 2 entities" with no errors

The publishing pitfalls split into resolution failures (unresolved $text, invalid spec, rejected kind) and linkage failures (missing owner, typo’d relation). The diagram groups them so the fix targets the file/config or the entity references.

OpenAPI-publishing pitfalls by class Unresolved $text, invalid spec, and rejected kind are resolution failures; missing owner and typo'd relation are linkage failures. Resolution $text unresolved invalid / kind rejected → fix path + rules Linkage unknown owner relation typo → ingest group + exact name
A missing providesApi relation is almost always a name typo — the value must match the API entity's name exactly.
Symptom Root Cause Resolution
spec.definition is the literal path, not the spec $text not resolved — file unreachable at ingestion Confirm the relative path is correct and the catalog reader has access to the repo
API page renders blank Spec is invalid OpenAPI Run redocly lint; the renderer silently fails on malformed documents
Owner shows as unknown group:payments-platform not ingested Register the Group entity first; ordering matters on a cold catalog
providesApi relation missing API name typo in providesApis The value must match the API entity metadata.name exactly
API kind rejected at ingestion catalog.rules omits API Add API to the allow list and refresh the location

Frequently Asked Questions

Can I reference a spec by URL instead of a relative path?

Yes — $text accepts an absolute URL, useful when the spec is published to an artifact store. Prefer the relative path when the spec lives in the same repo, because it stays versioned with the code and CI can fail on drift. A remote URL needs the catalog reader authorized for that host.

Does inlining a large spec bloat the catalog database?

A typical OpenAPI document is tens of kilobytes and inlines without issue. For very large specs, keep ownership and discovery in the catalog while linking out to a hosted artifact for the full document via spec.definition.$text pointing at a URL, so the entity row stays lean.