How-to › Ship an SDK

How to generate your first SDK with sdkgen#

Scaffold a project from your OpenAPI document with one non-interactive command, generate a TypeScript SDK, and read what the model made of your endpoints.

Audience
API producer
Level
beginner
Topic
Choose a generator and generate an SDK
Languages
TypeScript
Verified

You have an OpenAPI document and five languages of client to support. Hand-writing them means the same retry logic five times and a drift you find in production. Most generators answer with one method per endpoint, so GET /books/{id} becomes getBook and your users still assemble URLs in their heads.

What you get

You will end up with a TypeScript SDK whose classes are the things your API is about, generated from your document by two commands. This is for you if you own an API and nobody wants to maintain its clients by hand.

Short answer

Run npm create @voxgig/sdkgen with --def pointing at your OpenAPI document and --target ts, then run npm run generate inside the .sdk folder it wrote. The first command scaffolds and installs, the second builds the semantic model and writes the SDK. Read .sdk/model/entity before you use the output: it holds the classification every generated method comes from.

You will need

Node 22 or later and an OpenAPI 3 document. The document does not have to be perfect, but every operation needs a path and a method, and a schema on the response is what gives the generated types their fields. The OpenAPI 3.0 specification is the reference for what those pieces mean.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
sdkgenYou want entity-shaped clients and one set of features across languagesA semantic model to understand, and a smaller community than the alternativesThe API is a set of actions rather than resources
OpenAPI GeneratorYou want the widest language coverage and a large body of prior artEndpoint-shaped Api classes, and per-language templates to maintain when you customizeYou want one model to change rather than N template sets
KiotaA Microsoft stack, and you like a fluent request-builder APIA path-shaped builder chain rather than named operations, and a runtime dependency per languageYour users want objects that read like your domain
A hand-written fetch wrapperOne language, a small API, and unusual ergonomics that matterEvery fix applied once per language, and the drift you get when one is missedMore than one language, or an API that changes

Voxgig maintains sdkgen. It is the subject of this page, and its row in the table says when to pick something else. Read that one first.

The choice is really about what shape reaches your users. Generating from endpoints is a faithful transcription of the document. Extracting a model first is an opinion about what the document means, and an opinion can be wrong, which is why the section after next is about reading it.

Generate

Two commands. The first prompts when given no arguments, and every prompt has a flag, so this runs in a script or a CI job as easily as at a terminal.

npx --yes @voxgig/create-sdkgen@0.20.2 bookshelf --def api.json --target ts
cd bookshelf-sdk/.sdk
npm run generate

The scaffold writes bookshelf-sdk, copies your document to .sdk/def/, installs the project toolchain into .sdk/node_modules and adds the target. npm run generate is the step that builds the model and writes ts/. Re-run it after any edit under .sdk/.

Two folders matter afterwards, and they are not equal. .sdk/ is yours to edit: the model, the project overlay, and any components you override. ts/ is output, rewritten on every generate. Editing a file under ts/ works exactly once.

Read the model before you trust the SDK

The generated methods come from a classification, and the classification is a file you can read.

node inspect.mjs
document: 5 operations across 2 paths
  GET    /books             listBooks
  POST   /books             createBook
  GET    /books/{bookId}    getBook
  PATCH  /books/{bookId}    updateBook
  DELETE /books/{bookId}    deleteBook

model: 1 entity, 5 operations
  book(author, id, published, title)
  .create -> POST   /books
  .list   -> GET    /books
  .load   -> GET    /books/{bookId}
  .remove -> DELETE /books/{bookId}
  .update -> PATCH  /books/{bookId}

Five endpoints became one book entity with five named operations. load and remove share a path and are told apart by method. The fields come from the response schema, so a property you never documented is a property your users never get.

That is the whole argument for the model step. A caller writes book.load({ id }) rather than getBook(id), and the next language target produces the same five names.

The classification is a guess, and it is a guess made from shape rather than from intent. A path that ends in a variable segment and answers GET is a load; the same path under DELETE is a remove. That heuristic reads a REST-shaped document well, which is what the Richardson maturity model calls level 2, and reads an action-shaped one badly. Nothing about the guess is hidden: it is in .sdk/model/entity, in a format you can diff, and correcting it is editing that file rather than arguing with a template.

Add a target with npx voxgig-sdkgen target add go and the same model produces a Go SDK with the same five method names. That is the property that pays for the extra step, and it is the one you lose with a per-language template set.

Check it worked

The assertion worth making is that nothing in the document went missing, and that the classification is the one you expected.

test('every operation in the document reaches the model', () => {
  const inDoc = operations(doc).map((o) => `${o.method} ${o.path}`).sort()
  const inModel = modelOps(model).map((o) => `${o.method} ${o.path}`).sort()
  assert.deepEqual(inModel, inDoc)
})

test('five endpoints become one entity with the five entity operations', () => {
  assert.deepEqual(
    modelOps(model).map((o) => o.name).sort(),
    ['create', 'list', 'load', 'remove', 'update'],
  )
})
node --test inspect.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0

When it goes wrong

The first failure is an endpoint that is not one of the five. Add POST /books/{bookId}/checkout to the document and regenerate, and there is no book.checkout().

node action.mjs
document adds: POST /books/{bookId}/checkout (checkoutBook)
model puts it on: book.create, not book.checkout

every path the model carries, by the operation that holds it:
  book.create /books/{bookId}/checkout
  book.create /books
  book.list   /books
  book.load   /books/{bookId}
  book.remove /books/{bookId}
  book.update /books/{bookId}

entity methods generated: create, list, load, remove, update
a checkout method: no

It is not dropped. It becomes a second point of create, reached with an $action key, and the generated type says so:

export interface BookCreateData {
  author?: string
  id?: string
  published?: number
  title?: string

  // Selects a custom action instead of the plain create:
  //   'checkout'
  // The remaining keys are that action's own payload.
  $action?: string
  [action: string]: any
}

A caller looking for the checkout call reads create and does not find it. Grepping the SDK for checkout finds a comment. When an action matters to your users, name it in the model rather than leaving it as a selector on a verb that means something else.

The second failure is editing under ts/. Everything there is regenerated, and the loss is silent because the next generate succeeds. A change belongs in .sdk/: the model for a fact about the API, the project overlay for a decision about this project, a component override for output shape.

The third is a document whose responses carry no schema. The paths still classify, so the SDK generates and looks right, and every field type comes out as an open object. Fix the document rather than the model, because the same gap costs you again on the next target.

When not to do this

Do not reach for sdkgen for an API with one consumer that you also own. Two repositories and a regenerate step is more machinery than a function that calls fetch. The model buys you nothing when there is one caller and one language.

Do not adopt entity generation for an API that is genuinely a set of actions. A payments API whose verbs are authorize, capture and refund is not three-quarters of a CRUD entity, and forcing it into one makes every call read wrong.

Do not generate into a repository your users have already forked. Regeneration overwrites ts/, and somebody else’s edits live there now. Publish the package instead, and keep generation upstream.

Last verified

Verified 2026-09-07 against Node 22.22.2, @voxgig/create-sdkgen 0.20.2 and @voxgig/sdkgen 4.8.3. The three output blocks are what the preceding command printed. The scaffold and generate run itself is not repeated on each build. Its output is committed under generated/: the entity model and the generated types, from the run recorded here. The commands that read them are re-run.

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 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.