---
title: "Overview"
description: "A typed client for your API with zero runtime dependencies, in TypeScript, Python, or Go. Typed errors, auto-pagination, retries, hooks, and validation, generated from your spec."
url: https://typeship.dev/docs/platforms/sdk
markdown: https://typeship.dev/docs/platforms/sdk.md
section: "Get started"
---
> ## Documentation index
> Fetch the complete documentation index at https://typeship.dev/llms.txt (every page, one line each) or the full text at https://typeship.dev/llms-full.txt.
> Append .md to any docs URL, or send Accept: text/markdown, for the markdown twin of that page.

# Overview

A typed client for your API with zero runtime dependencies, in TypeScript, Python, or Go. Typed errors, auto-pagination, retries, hooks, and validation, generated from your spec.

The `sdk` platform is the foundation of every package typeship generates. It is always on. In TypeScript, the CLI and MCP server both import it. Python and Go packages are SDK-only.

This page uses TypeScript for the primary examples and shows the Python and Go equivalents where the shape differs. The idioms differ on purpose. Each SDK is written the way a developer in that language expects, not translated from one shared template. For the full language-specific surface, see [Python](https://typeship.dev/docs/platforms/sdk/python) and [Go](https://typeship.dev/docs/platforms/sdk/go).

Examples use the Acme API, whose package is `acme`. Your names come from your spec's title.

## What ships in the package

**TypeScript**

```text
acme/
  package.json          zero runtime dependencies, ESM, Node 18+
  src/index.ts          AcmeClient, ClientOptions, environments, VERSION
  src/types.ts          every schema as an interface or type
  src/errors.ts         per-status error classes and per-operation unions
  src/resources/*.ts    one module per resource
  src/core/             HTTP runtime and pagination
  src/webhooks.ts       when the spec declares webhooks
  src/schemas.ts        validation tables, plain data
  tests/*.test.mjs      one smoke test per operation, node:test only
  api.md                the full surface reference in one file
  AGENTS.md             context for coding agents
```

The package ships as readable TypeScript source and compiles with `npm run build`. `main`, `types`, and `exports` point into `dist/`.

**Python**

```text
acme/
  pyproject.toml        dependencies = [], requires-python >= 3.11
  acme/__init__.py      AcmeClient, errors, models, webhooks
  acme/models.py        TypedDicts and Literal enums
  acme/resources/*.py   one module per resource
  acme/_core.py         urllib runtime, retries, pagination
  acme/webhooks.py      when the spec declares webhooks
  acme/py.typed
  api.md
  AGENTS.md
```

**Go**

```text
acme-go/
  go.mod                no require block, go 1.21
  client.go             New, Option funcs, service fields
  models.go             structs, enums, params types
  accounts.go ...       one file per resource
  core.go               net/http runtime, retries
  pagination.go         Iter[T]
  webhooks.go           when the spec declares webhooks
  api.md
  AGENTS.md
```

Every file passes `gofmt`.

## Create a client

**TypeScript**

```ts
import { AcmeClient } from "acme";

const client = new AcmeClient({
  bearerToken: process.env.ACME_TOKEN!,
});
```

The TypeScript client does not read credentials from the environment. Pass them explicitly. This keeps the client safe in browsers and edge runtimes, where `process.env` may not exist. The generated CLI and MCP server are the pieces that read `ACME_TOKEN`.

**Python**

```python
from acme import AcmeClient

client = AcmeClient(bearer_token=os.environ["ACME_TOKEN"])
# or, with ACME_TOKEN set in the environment:
client = AcmeClient()
```

**Go**

```go
import acme "github.com/acme/acme-go"

client, err := acme.New(acme.WithBearerToken(os.Getenv("ACME_TOKEN")))
// or, with ACME_TOKEN set in the environment:
client, err := acme.New()
```

### Client options

Every option is optional when the spec pins a server URL. If it does not, `baseUrl` becomes a required argument and generation warns you.

**TypeScript**

```ts
const client = new AcmeClient({
  baseUrl: "https://api.acme.example.com/v1", // defaults to the spec's first server
  bearerToken: process.env.ACME_TOKEN!,
  timeoutMs: 30_000,      // per attempt
  maxRetries: 2,          // retries after the first attempt
  defaultHeaders: { "Request-Source": "billing" },
  fetch: myFetch,         // bring your own: proxies, tests, instrumentation
  onRequest: (context) => {},
  onResponse: (response, context) => {},
  onError: (error, request) => {},
  debug: false,
  validate: false,
});
```

**Python**

```python
client = AcmeClient(
    base_url="https://api.acme.example.com/v1",
    bearer_token=token,
    timeout=30.0,
    max_retries=2,
    default_headers={"Request-Source": "billing"},
    transport=my_transport,      # swap urllib for anything with the same signature
    on_request=..., on_response=..., on_error=...,
    debug=False,
    validate=False,
)
```

**Go**

```go
client, err := acme.New(
    acme.WithBaseURL("https://api.acme.example.com/v1"),
    acme.WithBearerToken(token),
    acme.WithTimeout(30*time.Second),
    acme.WithMaxRetries(2),
    acme.WithHTTPClient(httpClient),
    acme.WithOnRequest(func(r *http.Request) {}),
    acme.WithOnResponse(func(r *http.Response) {}),
    acme.WithOnError(func(err error, method, path string) {}),
    acme.WithDebug(func(e acme.DebugEvent) {}),
    acme.WithValidation(acme.ValidateOff),
)
```

### Authentication

Which auth options exist depends on the security schemes in your spec.

| Spec declares                       | TypeScript                           | Python                 | Go                                       |
| ----------------------------------- | ------------------------------------ | ---------------------- | ---------------------------------------- |
| HTTP bearer, OAuth2, OpenID Connect | `bearerToken`                        | `bearer_token`         | `WithBearerToken`, `WithBearerTokenFunc` |
| One API key header                  | `apiKey`                             | `api_key`              | `WithAPIKey`                             |
| Several API keys                    | one option per header or query param | one keyword per param  | one `With...` per param                  |
| HTTP basic                          | `basicAuth: { username, password }`  | `username`, `password` | `WithBasicAuth`                          |
| OAuth2 client credentials           | `clientCredentials`                  | `client_credentials`   | `WithClientCredentials`                  |

Bearer tokens and API keys accept a callback, resolved before every attempt. Use it for credentials that expire:

```ts
const client = new AcmeClient({
  bearerToken: async () => (await tokenManager.current()).accessToken,
});
```

When the spec declares OAuth2, `clientCredentials` handles the whole token lifecycle. The client posts to the spec's token URL, caches the token until a minute before expiry, and shares one in-flight token request across concurrent calls. An explicit bearer token always wins.

```ts
const client = new AcmeClient({
  clientCredentials: {
    clientId: process.env.ACME_CLIENT_ID!,
    clientSecret: process.env.ACME_CLIENT_SECRET!,
    scopes: ["read:accounts"],                             // optional
    tokenParams: { audience: "https://api.acme.example" }, // if your auth server needs one
    authMethod: "post",                                    // or "basic"
    tokenUrl: "https://auth.acme.example/oauth/token",     // required only if the spec has none
  },
});
```

Python takes `client_credentials={"client_id": ..., "client_secret": ...}`. Go takes `WithClientCredentials(acme.ClientCredentials{ClientID: ..., ClientSecret: ...})`. Both cache and lock the same way.

### Environments

When the spec declares two or more servers, the SDK exports them by name, taken from each server's description. The first server stays the default.

**TypeScript**

```ts
import { AcmeClient, environments } from "acme";

const client = new AcmeClient({ baseUrl: environments.sandbox, bearerToken: token });
```

**Python**

```python
from acme import AcmeClient, ENVIRONMENTS

client = AcmeClient(base_url=ENVIRONMENTS["sandbox"], bearer_token=token)
```

**Go**

```go
client, err := acme.New(acme.WithBaseURL(acme.ServerSandbox), acme.WithBearerToken(token))
```

## Call an operation

Operations are grouped into resources by their first tag. Path parameters are positional, in path order. The body comes next, then query and header parameters, then per-call options.

**TypeScript**

```ts
await client.accounts.get("acct_123");
await client.accounts.create({ name: "Ops", currency: "usd" });
await client.accounts.list({ limit: 50, created: { gte: 1700000000 } });
```

Method names are de-stuttered. An `operationId` of `createAccount` on the `accounts` resource becomes `client.accounts.create`. The type names follow: `AccountsCreateParams`, `AccountsCreateError`.

**Python**

```python
client.accounts.get("acct_123")
client.accounts.create(name="Ops", currency="usd")   # inline body fields become keyword arguments
client.accounts.update("acct_123", body={"name": "Ops 2"})   # a $ref body stays one typed argument
client.accounts.list(limit=50, created={"gte": 1700000000})
```

**Go**

```go
acct, err := client.Accounts.Get(ctx, "acct_123")
created, err := client.Accounts.Create(ctx, &acme.AccountsCreateParams{Name: "Ops"})   // inline body fields are struct fields
updated, err := client.Accounts.Update(ctx, "acct_123", acme.AccountParams{Name: "Ops 2"})   // a $ref body is its own argument
it := client.Accounts.List(ctx, &acme.AccountsListParams{Limit: acme.Ptr(int64(50))})
```

Every method takes a `context.Context` first. Params structs are passed by pointer, and `nil` means none. Optional fields are pointers so absent and zero stay distinct; `acme.Ptr(v)` makes one inline.

### Per-call options

The last argument of every method overrides the client for that one call.

**TypeScript**

```ts
await client.accounts.get("acct_123", {
  timeoutMs: 5_000,
  maxRetries: 0,
  headers: { "Request-Source": "cron" },
  signal: controller.signal,   // composed with the per-attempt timeout
});
```

**Python**

```python
client.accounts.get("acct_123", request_options={
    "timeout": 5.0,
    "max_retries": 0,
    "headers": {"Request-Source": "cron"},
})
```

**Go**

```go
acct, err := client.Accounts.Get(ctx, "acct_123",
    acme.WithRequestTimeout(5*time.Second),
    acme.WithRequestMaxRetries(0),
    acme.WithRequestHeader("Request-Source", "cron"),
)
```

Precedence is the same everywhere: per-call, then per-operation policy set at generation, then the client.

## Results and errors

**TypeScript**

Nothing throws on HTTP errors. Every call returns a discriminated `ApiResult`, and the error side is a union of the documented error classes for that exact operation:

```ts
type ApiResult<T, E> =
  | { ok: true; data: T; response: ResponseMeta }
  | { ok: false; error: E; response?: ResponseMeta };
```

```ts
import { AcmeClient, NotFoundError } from "acme";

const result = await client.accounts.get("acct_123");
if (!result.ok) {
  if (result.error instanceof NotFoundError) {
    result.error.body;   // typed body for the documented 404
  }
  throw result.error;    // every branch is an Error subclass
}
result.data;             // typed success payload
```

The union for each operation is built from four kinds of class:

* Per-status classes such as `NotFoundError`, generated from the spec's documented error responses. Each extends `ApiError` and carries `status`, a typed `body`, and `response` metadata (status, headers, request id).
* `UnexpectedApiError` for a status the spec did not document.
* `TransportError` when there was no HTTP response at all: network failure, timeout, abort. The message names the request and the deepest cause.
* `ValidationError` when [runtime validation](#runtime-validation) is on and a body fails its schema.

Prefer exceptions? `unwrap(result)` returns the data or throws the typed error:

```ts
import { unwrap } from "acme";

const account = unwrap(await client.accounts.get("acct_123"));
```

**Python**

Python raises. Every exception derives from `TypeshipError`, and API errors carry `status`, the parsed `body`, and `request_id`:

```python
from acme import ApiError, NotFoundError, TransportError

try:
    account = client.accounts.get("acct_123")
except NotFoundError as exc:
    print(exc.status, exc.body, exc.request_id)
except ApiError as exc:
    ...   # any documented or undocumented status
except TransportError:
    ...   # no HTTP response
```

Per-status classes such as `NotFoundError` and `UnprocessableEntityError` extend `ApiError`. An undocumented status raises `UnexpectedApiError`. Return values are the parsed JSON, typed as `TypedDict`s, so you read `account["id"]`.

**Go**

Go returns `(T, error)`. Typed error structs embed `APIError`, so `errors.As` matches at either precision:

```go
acct, err := client.Accounts.Get(ctx, "acct_123")
if err != nil {
    var notFound *acme.NotFoundError
    var apiErr *acme.APIError
    var transport *acme.TransportError
    switch {
    case errors.As(err, &notFound):
        // 404, notFound.Body holds the raw JSON
    case errors.As(err, &apiErr):
        // any status: apiErr.Status, apiErr.RequestID, apiErr.Decode(&target)
    case errors.As(err, &transport):
        // no HTTP response
    }
}
```

## Pagination

List operations with a recognized pagination shape iterate every item across every page, fetching lazily.

**TypeScript**

Paginated methods return a `PagePromise`. It is both awaitable and async-iterable:

```ts
// for await -> every item from every page
for await (const account of client.accounts.list({ limit: 50 })) {
  console.log(account.id);
}

// await -> one page, as a typed ApiResult
const result = await client.accounts.list({ limit: 50 });
if (result.ok) {
  result.data.items;               // Account[]
  result.data.body;                // the raw envelope
  result.data.hasNextPage();       // boolean
  await result.data.getNextPage(); // next Page, or null on the last one
}
```

Iteration throws the operation's typed error if any page fetch fails.

**Python**

Paginated methods return a generator. A `_page` sibling returns one raw page:

```python
for account in client.accounts.list(limit=50):
    print(account["id"])

page = client.accounts.list_page(limit=50)   # {"data": [...], "next_cursor": ...}
```

**Go**

Paginated methods return an `*Iter[T]` with `Next`, `Value`, and `Err`, the shape of `bufio.Scanner` and `sql.Rows`:

```go
it := client.Accounts.List(ctx, nil)
for it.Next() {
    account := it.Value()
    fmt.Println(account.ID)
}
if err := it.Err(); err != nil {
    return err
}
```

Per-call options passed to `List` apply to every page fetch.

Four pagination styles are detected from the spec:

| Style              | Detected from                                                                                                                                                                                                       |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cursor`           | A cursor query param (`cursor`, `page_token`, `after`, `starting_after`, ...) plus a next-cursor field in the response (`next_cursor`, `next_page_token`, ..., including inside a `meta` or `pagination` container) |
| `cursorFromLastId` | A cursor param plus a `has_more` field, where items have an `id`. The next request cursors from the last item's id.                                                                                                 |
| `page`             | A `page` or `page_number` query param. Advances while pages look full.                                                                                                                                              |
| `offset`           | An `offset`, `skip`, or `start` query param. Advances by the number of items received.                                                                                                                              |

Iteration stops when the API signals it: `has_more: false`, an empty next cursor, a page shorter than the requested limit, or an empty page.

Detection is heuristic, so it has an escape hatch. A project's [config](https://typeship.dev/docs/projects/config) can pin the style, items field, and cursor fields per operation, or turn pagination off for one. Malformed rules fall back to detection with a warning on the generation.

## Retries and timeouts

* Requests with idempotent verbs (GET, HEAD, PUT, DELETE, OPTIONS) retry on `408`, `429`, `500`, `502`, `503`, and `504`, and on transport failures.
* `429` is retried for every verb, including POST.
* Backoff is exponential with full jitter, starting at 300ms and capped at 10 seconds per wait. A `Retry-After` header (seconds or HTTP date) is honored, capped at 60 seconds.
* Defaults are two retries after the first attempt and a 30 second timeout per attempt.

The policy is tunable per project in [config](https://typeship.dev/docs/projects/config): replace the retryable status set, change the retry count and backoff window, allow retries on non-idempotent operations, or disable retries globally or per operation.

### Idempotency keys

When an operation declares an `Idempotency-Key` header, the SDK generates a UUID for it on every call you do not supply one for, and reuses that UUID across retries of the call. That is what makes retried writes safe. Pass your own through the params object (`idempotencyKey` in TypeScript, `idempotency_key` in Python, `IdempotencyKey` in Go) to control it.

## Hooks and debug logging

Two hooks run on every attempt. `onRequest` runs after auth and body headers are assembled and may mutate `headers` or `url`. `onResponse` runs after every HTTP response, before parsing and retry decisions. `onError` runs once per failed call, after retries, with the typed error and the request method and path.

```ts
const client = new AcmeClient({
  bearerToken: token,
  onRequest: (context) => {
    context.headers["Trace-Id"] = crypto.randomUUID();
  },
  onResponse: (response, context) => {
    metrics.observe(context.method, response.status, context.attempt);
  },
  onError: (error, request) => {
    logger.warn({ error, ...request });
  },
});
```

`debug: true` (or `ACME_DEBUG=1` in the environment) logs one line per attempt to stderr: `acme POST /accounts -> 201 (43ms) req_8f2k1`. Pass a function instead to feed your own logger a structured event with `method`, `path`, `status`, `durationMs`, `attempt`, `requestId`, and the transport error message when there was no response.

Debug events never include headers or bodies, so credentials cannot reach logs through them. When you need request-level detail, the hooks are the deliberate step up.

Every request carries a `User-Agent` of `acme/2.3.0 (typeship)`, so your API can tell SDK versions apart. Override it with `defaultHeaders`. The package exports a `VERSION` constant.

## Runtime validation

Types catch mistakes at compile time. They cannot see an API that drifted from its spec at runtime. Turn on validation to schema-check JSON request and response bodies against your spec's own schemas, still with zero dependencies. The validator ships in the package, and the schema table is plain data generated from the spec.

**TypeScript**

```ts
const client = new AcmeClient({ validate: true });

const result = await client.accounts.get("acct_123");
if (!result.ok && result.error instanceof ValidationError) {
  result.error.violations;
  // [{ path: "response.balance", message: "expected integer, got string" }]
}
```

Failures are never thrown. A `ValidationError` comes back as the error side of `ApiResult`. Use `validate: { mode: "warn" }` to log with `console.warn` and proceed, and `requests` / `responses` to toggle each direction.

**Python**

```python
client = AcmeClient(validate=True)      # raises ValidationError
client = AcmeClient(validate="warn")    # warnings.warn and proceed
```

`ValidationError.violations` is a list of `(path, message)` pairs. `direction` is `"request"` or `"response"`.

**Go**

```go
client, _ := acme.New(acme.WithValidation(acme.ValidateError))  // returns *ValidationError
client, _ := acme.New(acme.WithValidation(acme.ValidateWarn))   // reports through WithDebug and proceeds
```

Bad request bodies are caught before any HTTP is sent. The checks honor `readOnly`, `writeOnly`, and nullability. Constraints outside the emitted subset (`format`, `multipleOf`, and similar) are ignored, so validation can miss drift but never rejects valid traffic. All three languages validate against identical tables. GraphQL and streaming operations are not validated.

## Webhooks

When your spec declares a `webhooks` section (OpenAPI 3.1, or `x-webhooks` on 3.0), the package ships typed events and a verifying parser. Verification follows the Standard Webhooks convention: `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, HMAC-SHA256, a five minute tolerance, and a constant-time compare.

```ts
const client = new AcmeClient({ webhookKey: process.env.ACME_WEBHOOK_KEY });

// in your webhook handler:
const event = await client.webhooks.unwrap(rawBody, req.headers);
switch (event.type) {
  case "account.updated": event.account;    // fully typed per event
  case "account.closed":  event.account_id;
}
```

`unwrap` throws `WebhookVerificationError` on any mismatch. `unwrapUnsafe` parses without verifying. See [Webhooks](https://typeship.dev/docs/guides/webhooks) for the end-to-end flow, including local testing with your CLI.

## Streaming

Operations whose success response is `text/event-stream` return a stream of events instead of a parsed body. Each event has `data`, plus `event` and `id` when the server sends them.

**TypeScript**

```ts
const result = await client.events.stream();
if (result.ok) {
  for await (const event of result.data) {
    event.data; // string; JSON.parse it if your API sends JSON
  }
}
```

**Python**

```python
for event in client.events.stream():
    print(event.get("event"), event["data"])
```

**Go**

```go
stream, err := client.Events.Stream(ctx)
if err != nil { return err }
defer stream.Close()
for stream.Next() {
    ev := stream.Event()
    fmt.Println(ev.Event, ev.Data)
}
if err := stream.Err(); err != nil { return err }
```

The per-attempt timeout guards the connection and headers only, so long streams are not cut off. Once events are flowing nothing is retried, because replaying a dropped connection would redeliver events you already handled. A failure to connect surfaces before the first event.

Streaming operations are SDK-only. They are not exposed as CLI commands or MCP tools.

## Global parameters

APIs where every call carries the same tenant or version parameter should not make callers repeat it. Naming those parameters in a project's [config](https://typeship.dev/docs/projects/config) promotes them to client options: set once, applied to every operation that accepts them, with per-call values winning.

```ts
// generated with globals: ["account_id", "api-version"]
const client = new AcmeClient({ accountId: "acct_123", apiVersion: "2026-08" });

await client.reports.list();                          // both applied
await client.reports.list({ accountId: "acct_9" });   // per-call value wins
```

The generated CLI and MCP server read the same values from `ACME_ACCOUNT_ID`-style environment variables. Query and header parameters are supported. Path parameters are not.

## Types

### Enums

Named string enums generate a runtime value and a type with the same name, so values exist at runtime and the type stays a literal union:

```ts
export const AccountStatus = { ACTIVE: "active", FROZEN: "frozen", CLOSED: "closed" } as const;
export type AccountStatus = (typeof AccountStatus)[keyof typeof AccountStatus];

client.accounts.update("acct_1", { status: AccountStatus.FROZEN });
```

Python emits `Literal["active", "frozen", "closed"]`. Go emits a defined type with `AccountStatusActive`-style constants. Inline enums stay type-only literal unions.

### readOnly and writeOnly

Properties marked `readOnly` are omitted from request types, so the compiler stops you sending `id` or `created_at` fields the server owns. `writeOnly` properties are omitted from response types. In TypeScript a referenced schema appears as `Omit<Account, "id" | "balance">` in request positions. Python and Go materialize a named variant only when the filter removes something: `AccountWrite` in Python, `AccountParams` in Go. The canonical type keeps every property.

### Union types

`oneOf` and `anyOf` become TypeScript unions and Python `Union[...]`. Go has no sum types, so they become a named union type holding the raw JSON with `As<Variant>()`/`From<Variant>()` accessors and a `Discriminator()`, never a struct that would silently drop fields. See [Go: Unions](https://typeship.dev/docs/platforms/sdk/go#unions).

### Deep bracket encoding

Query objects and form-encoded bodies use bracket-style deep encoding:

```text
{ created: { gte: 5 } }         ->  created[gte]=5
{ items: [{ id: "x" }] }        ->  items[0][id]=x
{ expand: ["a", "b"] }          ->  expand=a&expand=b   (top-level arrays repeat the key)
new Date(...)                   ->  ISO 8601 string
```

JSON bodies are sent as JSON. This encoding applies to query strings and `application/x-www-form-urlencoded` bodies.

## GraphQL

Clients generated from a GraphQL schema put queries on `client.query` and mutations on `client.mutation` (`client.Query` / `client.Mutation` in Go). Generated methods select all scalar and enum fields to depth 2 by default, with a fragment per concrete type for unions and interfaces, and every object-returning method takes an optional selection that replaces the default: a typed `select` object in TypeScript that narrows the result type, `select=` in Python, `WithSelection(...)` in Go. The endpoint and auth scheme come from the project's [config](https://typeship.dev/docs/projects/config#graphql-schemas), since a schema cannot declare them. Relay-style connections auto-paginate. In-band errors surface as `GraphQLRequestError` (`*GraphQLError` in Go). See [Generate from GraphQL](https://typeship.dev/docs/guides/graphql).

## Naming rules

* Operations group into resources by their first tag. Untagged operations group by the first meaningful path segment. Version prefixes like `/v1` and `/api` are skipped.
* A meaningful `operationId` becomes the method name. Framework suffixes such as `UsingGET` are stripped.
* An `operationId` that merely restates method and path (`GetChargesCharge`) is treated as absent, and a name is derived by verb and path shape: `list`, `get`, `create`, `update`, `delete`. `GET /accounts` becomes `client.accounts.list()`. `GET /accounts/{id}` becomes `client.accounts.get(id)`.
* Well-known trailing action segments win over the generic verb: `POST /charges/{id}/capture` becomes `client.charges.capture(id)`.
* Resource words are trimmed from method names when the short form is unambiguous: `accounts.createAccount` becomes `accounts.create`.
* Schema names that would shadow a language global or a runtime export get a `Model` suffix: a schema named `Error` becomes `ErrorModel`.
* Go identifiers follow Go's initialism convention: `UserID`, `HTTPStatus`.

## Sitemap

[Every page of these docs](https://typeship.dev/llms.txt)
