How-to › Describe your API

How to write operationIds that generate clean SDK method names#

Name every operation with a unique lowercase verb-plus-noun id, audit the ids in CI, rename the bad ones with an overlay, and diff the methods two generators make.

Audience
API producer
Level
intermediate
Topic
Write an OpenAPI description
Verified

Your generated TypeScript client exports createBranchReposRepoBranchesPost and listBranches2. The first is a web framework’s default operationId turned into a method. The second is an id somebody copied, which the generator made unique by appending a digit, without a warning. Both names ship, and renaming either later breaks every caller.

What you get

You will end up with an audit that fails ids which would make bad method names, a rename pass that fixes them, and a diff of what two generators produce. This is for you if you publish an OpenAPI document that generators read.

Short answer

Give every operation an operationId in snake_case, verb then noun, such as list_branches, and keep it unique even once case is ignored. Audit the ids in CI, rename with an OpenAPI Overlay and the openapi-format casing pass, then generate with two generators and diff the method lists. Generators that name methods from operationId follow the rename. sdkgen and Kiota name calls from the path, so the path has to read well too.

You will need

Node 22 or later and an OpenAPI 3 document, ideally inside an authoring workflow that already lints it. Verified 2026-10-15 against Node 22.22.0, openapi-format 1.34.0, @hey-api/openapi-ts 0.99.0 and @voxgig/apidef 8.25.2. The OpenAPI 3.0.3 specification requires each operationId to be unique and case-sensitive, and recommends common programming naming conventions without naming one.1

Voxgig maintains sdkgen. This page compares how it names methods with OpenAPI Generator, Speakeasy, and Kiota.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
KiotaDeep path hierarchies, and callers who navigate by path segmentCall names come from path segments and HTTP verbs, so an operationId convention buys you nothing hereYour callers expect verb names your team chose
OpenAPI GeneratorThe widest language coverage, and ids you already controlA duplicate inside one tag gets _0 appended with only a log warningYou want naming decided by a model rather than by each id
sdkgenEntity-shaped clients whose paths already name the resourceReads an operationId only when a path has no segment to name an entity, so the paths carry the namingYour paths are actions or RPC verbs rather than resources
SpeakeasyIds you control, grouped by tag or x-speakeasy-groupMethod names track the operationId unless x-speakeasy-name-override decouples them, so a rename breaks callersYou cannot add vendor extensions to the published document

OpenAPI Generator and Speakeasy turn each id into a method, so a bad id ships as a bad method and a renamed id is a breaking change. Speakeasy files each method under a namespace named after its tag. Kiota and sdkgen build names from the path, which makes the ids nearly irrelevant to them and the path segments the thing to get right. One convention serves both groups: snake_case verb plus noun, on paths that name resources.

Find the ids that would become bad names

The audit asks of each id whether it exists, is unique, is not a framework default, is snake_case, and starts with a verb. Uniqueness is checked twice: as written, then with case and punctuation folded away. The framework check compares the id with its own path and method, so get_branch_head and create_post pass it.

// FastAPI's default is the function name, then the path with every non-word
// character turned into `_`, then the method: `create_branch_repos__repo__branches_post`.
// So the id ends with its own operation's path and method, and a doubled
// underscore usually marks it. An id that merely ends in a method name, such
// as `get_branch_head` or `create_post`, is not one. The comparison is folded,
// so the mark survives a casing pass that rewrote the id.
export function frameworkDefault({ id, method, path }) {
  const tail = folded(path)
  return id.includes('__') || (tail !== '' && folded(id).endsWith(tail + method.toLowerCase()))
}

/** The problems with one operation's id, given every id in the document. */
export function problems(op, exact, fold) {
  const { id, unread } = op
  if (unread) return ['in another file, bundle first']
  if (id === undefined || id === '') return ['missing']
  if (typeof id !== 'string') return ['not a string']
  const found = []
  if (exact.get(id) > 1) found.push('duplicate')
  else if (fold.get(folded(id)) > 1) found.push('collides after re-casing')
  if (frameworkDefault(op)) found.push('framework default')
  if (!SNAKE.test(id)) found.push('not snake_case')
  else if (!VERBS.includes(id.split('_')[0])) found.push('no verb first')
  return found
}

Run it on the sample document, a small repository-hosting API with branches and tags:

node ids.mjs api.json
api.json: 6 operations, 6 with a problem
  GET    /repos/{repo}/branches           listBranches                              duplicate, not snake_case
  POST   /repos/{repo}/branches           create_branch_repos__repo__branches_post  framework default, not snake_case
  GET    /repos/{repo}/branches/{branch}  GetBranch                                 not snake_case
  PATCH  /repos/{repo}/branches/{branch}  (none)                                    missing
  DELETE /repos/{repo}/branches/{branch}  delete-branch                             not snake_case
  GET    /repos/{repo}/tags               listBranches                              duplicate, not snake_case

The POST id is FastAPI’s default. Its generate_unique_id joins the function name, the path with every non-word character replaced by an underscore, and the method.2 The audit exits 1 here, so the same command can gate a pull request.

Rename with an overlay, then fix the case

A casing tool can turn GetBranch into get_branch, but it cannot know that the tags endpoint means list_tags, because that is a change of words rather than of case. Put the word changes in an OpenAPI Overlay, a reviewed file of targeted edits, and leave case to a rule.

    {
      "target": "$.paths['/repos/{repo}/branches'].post",
      "description": "FastAPI's default id, function name plus path plus method",
      "update": { "operationId": "create_branch" }
    },

openapi-format applies the overlay first and the --casingFile rule after it, in one run. The casing file holds one rule, "operationId": "snake_case".

npx openapi-format api.json --no-sort --overlayFile rename.overlay.json --casingFile casing.json -o api.clean.json && node ids.mjs api.clean.json
api.clean.json: 6 operations, 0 with a problem
  GET    /repos/{repo}/branches           list_branches                             ok
  POST   /repos/{repo}/branches           create_branch                             ok
  GET    /repos/{repo}/branches/{branch}  get_branch                                ok
  PATCH  /repos/{repo}/branches/{branch}  update_branch                             ok
  DELETE /repos/{repo}/branches/{branch}  delete_branch                             ok
  GET    /repos/{repo}/tags               list_tags                                 ok

Keep the overlay beside the code that emits the document. A framework writes its defaults again on every export, and the overlay re-applies to each one.

Generate twice and diff the method lists

The audit says the ids are clean. The generators decide what that is worth. methods.mjs runs @hey-api/openapi-ts, which names functions from operationId, and @voxgig/apidef, the model step inside sdkgen, which names them from the path. Both run on the document before and after the rename.

node methods.mjs
operationId-driven, @hey-api/openapi-ts 0.99.0, api.json -> api.clean.json
  GET    /repos/{repo}/branches           listBranches                       -> listBranches
  POST   /repos/{repo}/branches           createBranchReposRepoBranchesPost  -> createBranch
  GET    /repos/{repo}/branches/{branch}  getBranch                          -> getBranch
  PATCH  /repos/{repo}/branches/{branch}  patchReposByRepoBranchesByBranch   -> updateBranch
  DELETE /repos/{repo}/branches/{branch}  deleteBranch                       -> deleteBranch
  GET    /repos/{repo}/tags               listBranches2                      -> listTags
  3 of 6 method names changed

path-driven, @voxgig/apidef 8.25.2 (the sdkgen model step), api.json -> api.clean.json
  GET    /repos/{repo}/branches           branch.list                        -> branch.list
  POST   /repos/{repo}/branches           branch.create                      -> branch.create
  GET    /repos/{repo}/branches/{branch}  branch.load                        -> branch.load
  PATCH  /repos/{repo}/branches/{branch}  branch.update                      -> branch.update
  DELETE /repos/{repo}/branches/{branch}  branch.remove                      -> branch.remove
  GET    /repos/{repo}/tags               tag.list                           -> tag.list
  0 of 6 method names changed

The three names that changed are the three the overlay renamed. The casing pass changed no TypeScript method, because the generator re-cases every id into camelCase itself. The missing id became patchReposByRepoBranchesByBranch, a name the generator built from the method and path. The copied id became listBranches2.

The path-driven column did not move. sdkgen read the same six operations as one branch entity with five operations and one tag entity with one. Paths and methods were all it read. For it, the rename was a no-op, and a path such as /repos/{repo}/branches is what produced branch.list.

Check it worked

Twelve tests run, covering the audit, the rename pass, and both generators.

node --test ids.test.mjs
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0

One test regenerates api.clean.json from the committed overlay and casing file and compares it with the committed copy, so the rename cannot drift from its record. Another asserts that the casing pass alone leaves create_branch_repos_repo_branches_post, the duplicate, and the missing id in place.

When it goes wrong

The specification accepts listBranches beside list_branches, because it treats the value as case-sensitive. The generator re-cases both to listBranches and names the second listBranches2, and by default it prints no warning. Its opt-in validator, parser.validate_EXPERIMENTAL, stops on an exact duplicate but compares ids as written, so it passes this pair; a test pins all three. The audit folds case before counting for this reason. OpenAPI Generator appends _0 to a duplicate inside one tag and logs a warning, in its DefaultCodegen.3 Speakeasy’s linter reports a collision as an error, under its generator-duplicate-operation-name rule.

The casing pass invents words. listOAuthApps converts to list_o_auth_apps, because camelCase hides where an acronym ends; a test pins that too. Write the id in snake_case at the source, where the word boundaries are explicit, rather than converting it afterwards.

A path with no literal segment, such as GET / or GET /{check}, takes its entity name from the operationId in sdkgen. The model step in apidef 8.25.2 falls back to the id there, ahead of the response schema’s title, and the sample’s root.json produces get_service_status.load. That fallback is the one place the model step uses the id to name anything.

When not to do this

Do not rename the ids behind an SDK you have already published as a minor release. In OpenAPI Generator and Speakeasy the method name is the id, so the rename is a breaking change. Add x-speakeasy-name-override or OpenAPI Generator’s --operation-id-name-mappings instead, and rename on the next major version.

Do not fill missing ids from a template. The openapi-format generate option named the PATCH patch_branches in the tests, a method and a plural that describe neither the intent nor the single branch it changes.

Do not expect a clean operationId convention to fix sdkgen or Kiota output. Both name calls from the path, so a path whose segments name no resource produces a poor client whatever its id says. Fix the path, or accept that the convention serves the id-driven generators only.

Last verified

Verified 2026-10-15 against Node 22.22.0, openapi-format 1.34.0, @hey-api/openapi-ts 0.99.0 and @voxgig/apidef 8.25.2. Every output block is what the command preceding it printed. OpenAPI Generator, Speakeasy, and Kiota were not run; their behavior is taken from the source and documentation linked on this page. The sdkgen names come from @voxgig/apidef run on its own, the step of an sdkgen generate that classifies the paths.

Footnotes

  1. The 3.0.3 text asks for an id that is unique among all operations and case-sensitive. Taken together, the two rules permit listBranches and list_branches in one document. It then recommends following common programming naming conventions, a phrase that names no convention, which leaves the choice to whichever generator reads the id next. ↩︎ Back to text

  2. FastAPI’s guide to generating clients shows the method its own default produces, createItemItemsPost, and concedes that it does not read cleanly. It also says why the default looks that way: the specification requires unique ids, and a function name plus a path plus a method is unique by construction. Readability arrives further down that guide, as a custom generate_unique_id_function that the reader writes. ↩︎ Back to text

  3. The loop is in DefaultCodegen.addOperationToGroup. Its counter starts at zero, so a second listBranches becomes listBranches_0 and a third becomes listBranches_1. It compares only the operations already filed under the same tag. Two identical ids under two different tags pass through unchanged, into two different API classes. ↩︎ 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.