By the Octri team · Updated
How to build an SDK: a step-by-step guide with code
A usable SDK begins with a piece of application code that you would be happy for someone to copy into production. The HTTP requests come later. Starting from the caller's side forces decisions about method names, arguments, error types and what happens when the network gives an uncertain answer.
How do you build an SDK?
Choose one supported language and one realistic workflow. Design the public methods, implement a shared transport, then add types, response parsing, errors and policy for retries. Test against a fake API before touching production. A package that supports one complete workflow with predictable behavior teaches you more than a hundred empty method stubs.
The example here is a small TypeScript orders client. It is deliberately narrow so the important decisions are visible.
Start with the calls you want developers to write
A customer of the API should think in orders, not in paths and request bodies:
const acme = new Acme({ apiKey: process.env.ACME_KEY! });
const order = await acme.orders.create({ sku: 'book', quantity: 2 });
console.log(order.id);This short interface commits you to a surprising amount. The constructor owns credentials and a base URL. orders groups related work. create takes one object that can grow without disrupting existing positional arguments. The returned value must have a documented shape, and a failure must be distinguishable from a successful result.
Build the client and the request function
Give every method one internal request function. It should construct URLs safely, attach credentials, enforce timeouts and parse both successful and failed responses. Inject the transport when possible; a client that accepts fetch can be tested without a network.
type Options = { apiKey: string; baseUrl?: string; fetcher?: typeof fetch };
type Order = { id: string; sku: string; quantity: number };
type Page<T> = { data: T[]; nextCursor: string | null };
class Acme {
readonly baseUrl: string;
readonly fetcher: typeof fetch;
readonly orders = { create: (body: { sku: string; quantity: number }) =>
this.request<Order>('POST', '/orders', body) };
constructor(private options: Options) {
this.baseUrl = options.baseUrl ?? 'https://api.acme.example/v1';
this.fetcher = options.fetcher ?? fetch;
}
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await this.fetcher(this.baseUrl + path, {
method,
headers: { Authorization: `Bearer ${this.options.apiKey}`,
'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`API returned ${res.status}`);
return (await res.json()) as T;
}
}This is a foundation, not a finished release. It does not yet expose typed status errors, a retry policy or pagination, and a production URL needs to be configured rather than copied from the illustrative domain.

Retry safely, with backoff and idempotency keys
Retrying is where a library can cause damage while appearing helpful. A timeout does not tell you whether a write happened. For operations that support idempotency, create one key before the first attempt and reuse it if you retry. Respect Retry-After for a 429, add bounded exponential backoff for transient failures, and let callers opt out.
I checked this boundary with a tiny Node test using an injected fetch. The first response was 503, the second 201; the client made two attempts, and both carried the same Idempotency-Key. Generating the key inside the retry loop would have given those two attempts different identities. The test did not exercise a live payment API, but it demonstrated the exact client-side property a safe retry needs.
// Illustrative assertion from the mock test:
assert.deepEqual(sentKeys, ['fixed-operation-key', 'fixed-operation-key']);The server must honor that key too; adding a header alone does not make a POST safe. See idempotency keys.

Turn errors into types
An Error('API returned 404') works for a prototype but makes consumers parse messages. Add a common error base with status, request ID and parsed body. Distinguish status errors from connection failures where no response arrived. A caller should be able to catch NotFoundError, inspect an identifier and decide what to do without relying on English wording.
Keep secrets out of messages. An exception should tell a developer enough to locate the failure, not include a serialized Authorization header.
Add resources, models and pagination
Group methods by the nouns in the API, and use named request and response types. In TypeScript, typed input objects make autocomplete useful; in Python, a similar client may use dataclasses or Pydantic models. Pagination deserves a consistent abstraction rather than expecting every caller to follow the same cursor manually.
async *listAll(): AsyncGenerator<Order> {
let cursor: string | undefined;
do {
const page = await this.request<Page<Order>>('GET',
`/orders${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
yield* page.data;
cursor = page.nextCursor ?? undefined;
} while (cursor);
}This iterator can be added to the client once its request<T> method is in place; the example uses the Order and Page<T> types defined above. Test the page boundary and a failed second page before exposing it as a public helper.
Test the SDK
Test request construction through an injected transport or mock server: path, method, credential, body and headers. Follow that with non-happy paths: malformed bodies, 429, an exhausted retry budget, a timeout after a possible write and multiple result pages. Add a contract check against the spec and a sandbox smoke test before a release.
Don't stop when the server returns 200. A client can get the right answer while sending an unwanted extra request or swallowing a useful error.
Document, publish and version
Write a README that starts with installing the real package, configuring credentials and running one method. Build the package and install its tarball into a fresh project; this catches missing compiled files and broken entry points. Version breaking changes deliberately, and put migration notes next to the changelog.
Publishing is a separate discipline: npm publishing covers package contents, tags and trusted CI releases.
From one language to many
The transport responsibilities stay similar across languages; the public interface does not. Python needs a clear sync/async story. Go tends to return (value, error) and accepts contexts. Java may use builders. Resist copying TypeScript conventions blindly into every generated target.
Checklist before you publish your SDK
Make a clean install, successful call, pagination run, controlled retry and deliberate failure. Confirm the request ID reaches the caller's exception. Check runtime versions and package exports. Then ask someone unfamiliar with the implementation to use the quickstart without looking inside the library.
Building SDKs with Octri
Octri can generate much of the client surface from an OpenAPI description while allowing its public interfaces and behavior to be configured per language. That is useful after you have decided what you want an SDK call to look like, not as a substitute for that decision.
