How-to › Describe your API

How to organize operations with tags that docs and SDKs respect#

Give every operation one declared, described tag in reading order, audit the list in CI, and measure what a tag rename moves in a tag-driven generator and in sdkgen.

Audience
DevRel
Level
beginner
Topic
Write an OpenAPI description
Verified

Your reference renders a Customers section at the bottom of the sidebar with no description, because the tag is used on an operation and declared nowhere. The generated SDK has createPayment in two classes, because that operation carries two tags. Renaming a tag to tidy the sidebar renames a namespace that every caller imports.

What you get

You will end up with a tag list declared once, described, ordered for readers, and audited in CI, plus a measured view of what a tag rename does in two generators. This is for you if one document feeds your docs and your SDKs.

Short answer

Give every operation exactly one tag, and declare each tag once in the top-level tags list with a description. Order that list the way readers should meet the API, because documentation tools follow it. Audit the list in CI with the Spectral tag rules and a check for unused tags. Treat a tag rename as an SDK breaking change for tag-driven generators such as OpenAPI Generator and Speakeasy, and expect sdkgen to name methods from schemas first and tags second.

You will need

Node 22 or later and an OpenAPI 3.1 document with an operationId on every operation. Verified 2026-10-21 against Node 22.22.0, @stoplight/spectral-cli 6.17.0, orval 8.43.0, @voxgig/apidef 8.25.3, and yaml 2.9.1. The OpenAPI Object says the order of the top-level tags list “can be used to reflect on their order by the parsing tools” and leaves the rest to the tool. Tags used but not declared “MAY be organized randomly or based on the tools’ logic.”1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
OpenAPI 3.2 tag parent and kindA hierarchy and an audience marker inside the standard, for tools that read 3.2A 3.2 document, which few generators accept, so the hierarchy is inert in every tool that stops at 3.1Your toolchain stops at 3.1, or your renderer already groups with x-tagGroups
OpenAPI Generator API classesOne client class per tag in dozens of languages, with default for untagged operationsAn operation with two tags is generated twice, and a tag rename renames a class every caller importsYour tags were chosen for the sidebar, not for a class list
Redocly x-tagGroupsA second navigation level in Redoc and Redocly, over tags you keep flatA vendor extension, a tag in no group vanishes from the output, and SDK generators ignore itYour renderer is neither Redoc nor Scalar, or 3.2 parent is open to you
sdkgen entity namespacesEntity-shaped clients named from your component schemas, so tags stay a documentation deviceA schema rename moves methods instead, and an operation with an inline schema takes its first tag as its entityYour SDK should mirror the sidebar, tag for tag
Speakeasy tag namespacesTag-driven namespaces, with x-speakeasy-group to override them per operationA method appears in every class its tags name, and an untagged one lands on the root clientYou cannot add vendor extensions to the published document

Voxgig maintains sdkgen. It is one of five options here, not the recommendation.

OpenAPI Generator and Speakeasy read tags as the shape of the SDK, so every tag decision is an API decision for them. An operation with two tags becomes a method with two homes. Redoc reads tags as the shape of the sidebar and x-tagGroups as a layer over it. sdkgen reads tags last: the component schema an operation references names its entity, and the tag steps in only where no schema does. One document serves all three only when tags, schemas, and paths agree.

Audit the tags before a renderer does

The sample is a billing API with eight operations and the four mistakes a tag list collects over time. A declared tag goes unused, an operation carries two tags, a tag is used but never declared, and the health check is untagged. tags.mjs reads a document and reports all four.

/** The problems with a document's tags: per declared tag, then per operation. */
export function audit(doc) {
  const declared = doc.tags ?? []
  const ops = operations(doc)
  const uses = new Map(declared.map((t) => [t.name, 0]))
  const names = new Set(declared.map((t) => t.name))
  const operationProblems = ops.map((op) => {
    const problems = []
    if (op.tags.length === 0) problems.push('untagged')
    if (op.tags.length > 1) problems.push(`${op.tags.length} tags`)
    for (const tag of op.tags) {
      if (names.has(tag)) uses.set(tag, uses.get(tag) + 1)
      else problems.push(`${tag} is not declared`)
    }
    return { ...op, problems }
  })
  const tagProblems = declared.map((tag) => {
    const problems = []
    if (!tag.description) problems.push('no description')
    if (uses.get(tag.name) === 0) problems.push('unused')
    return { name: tag.name, uses: uses.get(tag.name), problems }
  })
  return { tags: tagProblems, operations: operationProblems }
}
node tags.mjs openapi.yaml
openapi.yaml: 3 tags declared, 8 operations, 4 with a problem
  tag    Invoices     used by 4 ok
  tag    Payments     used by 3 ok
  tag    Refunds      used by 0 unused
  GET    /invoices                Invoices             ok
  POST   /invoices                Invoices             ok
  GET    /invoices/{invoice_id}   Invoices             ok
  GET    /payments                Payments             ok
  POST   /payments                Invoices, Payments   2 tags
  GET    /payments/{payment_id}   Payments             ok
  GET    /customers               Customers            Customers is not declared
  GET    /health                  (none)               untagged

The script exits 1 here, so the same command can gate a pull request. The Spectral OpenAPI ruleset covers three of the four with built-in rules. The ruleset beside the document turns on those three and the two rules for the top-level list, and leaves the rest of spectral:oas off:

extends: [[spectral:oas, off]]
rules:
  openapi-tags: error
  openapi-tags-uniqueness: error
  operation-tags: error
  operation-tag-defined: error
  operation-singular-tag: error
npx spectral lint openapi.yaml
  89:12  error  operation-singular-tag  Operation must not have more than a single tag.  paths./payments.post.tags
 126:14  error  operation-tag-defined   Operation tags must be defined in global tags.   paths./customers.get.tags[0]
  137:9  error  operation-tags          Operation must have non-empty "tags" array.      paths./health.get

✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints)

operation-singular-tag is off in the default ruleset, so a team that wants one tag per operation has to ask for it. The unused Refunds tag has no rule in Spectral at all, which is why the script exists beside it.

Declare every tag once, described, in reading order

The fixed document declares four tags, each with a description. It orders them the way a reader meets the API: the resource people pay for, the money against it, who owes it, and the service itself last. The health check gets a tag of its own, Operations, instead of a seat in Invoices.

tags:
  - name: Invoices
    description: Invoices raised against a customer.
  - name: Payments
    description: Payments recorded against an invoice.
  - name: Customers
    description: The customers invoices are raised against.
  - name: Operations
    description: Health and readiness of the service itself.
node tags.mjs openapi.fixed.yaml
openapi.fixed.yaml: 4 tags declared, 8 operations, 0 with a problem
  tag    Invoices     used by 3 ok
  tag    Payments     used by 3 ok
  tag    Customers    used by 1 ok
  tag    Operations   used by 1 ok

Tag by resource unless two audiences read two different documents. A resource tag names a noun your schemas already name, so the docs sidebar and an SDK class list come out the same. An audience tag such as Partners reads well in a sidebar and becomes a class called Partners in a tag-driven SDK, holding whichever operations partners happen to call. OpenAPI 3.2 lets a tag say which kind it is, and nest under another, so an audience tag can be declared as one:

tags:
  - name: Billing
    summary: Billing
    description: Everything a finance team touches.
    kind: nav
  - name: Invoices
    description: Invoices raised against a customer.
    parent: Billing
    kind: nav
  - name: Payments
    description: Payments recorded against an invoice.
    parent: Billing
    kind: nav
  - name: Partners
    description: Operations open to partner integrations, whichever resource they touch.
    kind: audience

The 3.2 Tag Object defines parent as the name of a tag this one nests under. It defines kind as “a machine-readable string to categorize what sort of tag it is” and names nav, badge, and audience as common values.2 Until your renderer reads 3.2, Redocly’s x-tagGroups puts the same hierarchy in an extension. The page on organizing a large OpenAPI reference by tags and tag groups covers what Redoc and Scalar do with it.

See where each generator files an operation

namespaces.mjs runs two generators on the fixed document and on a copy with every tag renamed, and prints where each operation landed. orval in tags mode writes one file per tag, which makes it the runnable stand-in here for every generator that reads tags as namespaces. @voxgig/apidef is the model step inside sdkgen.

/**
 * orval in tags mode writes one file per tag and names each function after the operationId.
 * Returns the file each operation landed in, keyed by method and path.
 */
export function byTag(file) {
  const out = mkdtempSync(join(tmpdir(), 'orval-'))
  try {
    // orval reads the nearest tsconfig.json up the tree, so point it at the sample's own.
    const tsconfig = fileURLToPath(new URL('./tsconfig.json', import.meta.url))
    const args = [ORVAL, '-i', resolve(file), '-o', join(out, 'api.ts'), '-m', 'tags', '--client', 'fetch', '--tsconfig', tsconfig, '--quiet']
node namespaces.mjs openapi.fixed.yaml openapi.renamed.yaml
orval 8.43.0, mode tags, openapi.fixed.yaml -> openapi.renamed.yaml
  GET    /invoices                invoices       -> billing
  POST   /invoices                invoices       -> billing
  GET    /invoices/{invoice_id}   invoices       -> billing
  GET    /payments                payments       -> money
  POST   /payments                payments       -> money
  GET    /payments/{payment_id}   payments       -> money
  GET    /customers               customers      -> accounts
  GET    /health                  operations     -> platform
  8 of 8 operations moved

@voxgig/apidef 8.25.3, the sdkgen model step, openapi.fixed.yaml -> openapi.renamed.yaml
  GET    /invoices                invoice.list   -> invoice.list
  POST   /invoices                invoice.create -> invoice.create
  GET    /invoices/{invoice_id}   invoice.load   -> invoice.load
  GET    /payments                payment.list   -> payment.list
  POST   /payments                payment.create -> payment.create
  GET    /payments/{payment_id}   payment.load   -> payment.load
  GET    /customers               customer.list  -> customer.list
  GET    /health                  operation.load -> platform.load
  1 of 8 operations moved

Every orval file moved, because the file is the tag. Speakeasy documents the same relation for its namespaces, and OpenAPI Generator’s DefaultGenerator files an operation under a group per tag, with a tag named default for an operation that has none.3

apidef moved one method. Seven operations reference a component schema, Invoice, Payment, or Customer, and that schema names the entity, so the tag rename did not reach them. The health check returns an inline object, so apidef fell back to its tag, and Operations became operation.load. The apidef heuristic reads a referenced schema first, the operation’s first tag second, and the path last, which is a narrower claim than “sdkgen ignores tags”. Name your schemas and the tags stay a documentation device.

Check it worked

The test file covers the audit and the Spectral run, then both generators before and after the rename.

node --test tags.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7343.578503

The third test asserts that orval files the draft’s two-tag operation under invoices, the first of its tags, and the untagged health check under default. The fourth pins the rename result from the previous step: eight orval files moved, one apidef method moved, and that one is the health check.

When it goes wrong

A section appears at the bottom of the sidebar with no description. The tag is used on an operation and missing from the top-level list, which the specification allows and then declines to order. Declare it, and keep operation-tag-defined at error so the next one fails the build.

A method appears twice in the generated SDK. The operation carries two tags, and Speakeasy says that such an operation “will appear as a method in multiple classes” and OpenAPI Generator’s group loop does the same. orval takes the first tag only, so three generators give the operation two, two, or one homes. Keep one tag per operation, and group for the sidebar with x-tagGroups or parent.

A tag rename breaks callers of the SDK. The rename run in the previous step moved eight of eight orval files, and the tag-driven generators move their classes the same way. Rename in a major version, or change only the label: Redoc reads x-displayName and Speakeasy reads x-speakeasy-group, so the sidebar can change while the namespace stays.

When not to do this

Do not tag by audience when an SDK is generated from tags. Partners and Internal are useful sidebar sections and poor class names, and a tag-driven generator will mint the classes anyway. Keep audience as a 3.2 kind or an x-tagGroups entry, and keep the tags themselves on resources.

Do not expect tags to shape an sdkgen client. apidef names an entity from the component schema an operation references, and reaches for the tag only when there is none. A tag rename therefore moves the operations with inline schemas and leaves the rest. If a method lands in the wrong entity in sdkgen, the fix is a named schema or a clearer path, and a tag change will not do it.

Do not give an operation two tags to make it findable in two sidebar sections. Redoc shows it twice, Speakeasy and OpenAPI Generator generate it twice, and orval files it once under the first tag, so the document means three different things to three readers. Cross-reference in the description instead.

Do not treat x-tagGroups as portable. It is one vendor’s extension that another vendor also reads, and a tag outside every group disappears from Redoc’s output.

Last verified

Verified 2026-10-21 against Node 22.22.0, @stoplight/spectral-cli 6.17.0, orval 8.43.0, @voxgig/apidef 8.25.3, and yaml 2.9.1. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci. OpenAPI Generator, Speakeasy, Redoc, and Scalar were not run; their behavior is taken from the documentation and source linked on this page. The sdkgen names come from @voxgig/apidef run on its own, the step of an sdkgen generate that reads the document into entities. The 3.2 tags file was written for the page and fed to no tool.

Footnotes

  1. The sentence permitting random order reads the same in 3.1 and in 3.2, word for word. The revision between them gave the Tag Object three fields and a registry and left that sentence alone. A specification that tells you how to nest a tag still declines to say where an undeclared one goes, which leaves that question to whichever renderer opens the file. ↩︎ Back to text

  2. The 3.2 Tag Object points to a registry of common kind values and offers three: nav for navigation, badge for visible badges, and audience for the groups an API serves. x-tagGroups arrived in Redoc 1.7.0 in January 2017 and expressed the first of the three, navigation, for years before the standard named any of them. ↩︎ Back to text

  3. The three tools give an operation with no tag three different homes. OpenAPI Generator’s DefaultGenerator adds a tag named default and files the operation under it. orval writes a default.ts. Speakeasy puts the method on the root SDK class, which has no name of its own to lose. The word default is a tag the document never declared, so it would fail the audit on this page if it were written down. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.