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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
OpenAPI 3.2 tag parent and kind | A hierarchy and an audience marker inside the standard, for tools that read 3.2 | A 3.2 document, which few generators accept, so the hierarchy is inert in every tool that stops at 3.1 | Your toolchain stops at 3.1, or your renderer already groups with x-tagGroups |
| OpenAPI Generator API classes | One client class per tag in dozens of languages, with default for untagged operations | An operation with two tags is generated twice, and a tag rename renames a class every caller imports | Your tags were chosen for the sidebar, not for a class list |
Redocly x-tagGroups | A second navigation level in Redoc and Redocly, over tags you keep flat | A vendor extension, a tag in no group vanishes from the output, and SDK generators ignore it | Your renderer is neither Redoc nor Scalar, or 3.2 parent is open to you |
| sdkgen entity namespaces | Entity-shaped clients named from your component schemas, so tags stay a documentation device | A schema rename moves methods instead, and an operation with an inline schema takes its first tag as its entity | Your SDK should mirror the sidebar, tag for tag |
| Speakeasy tag namespaces | Tag-driven namespaces, with x-speakeasy-group to override them per operation | A method appears in every class its tags name, and an untagged one lands on the root client | You 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.
Related how-tos
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
-
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
-
The 3.2 Tag Object points to a registry of common
kindvalues and offers three:navfor navigation,badgefor visible badges, andaudiencefor the groups an API serves.x-tagGroupsarrived 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 -
The three tools give an operation with no tag three different homes. OpenAPI Generator’s
DefaultGeneratoradds a tag nameddefaultand files the operation under it. orval writes adefault.ts. Speakeasy puts the method on the root SDK class, which has no name of its own to lose. The worddefaultis a tag the document never declared, so it would fail the audit on this page if it were written down. ↩︎ Back to text