Writing API Clients that Developers Love to Use

A good API client does not just send HTTP requests.

That is the bare minimum.

A good API client makes an API feel natural inside the language where it is being used. It hides the annoying parts without hiding the important parts. It turns documentation into discoverable code. It helps developers move quickly, make fewer mistakes, and recover cleanly when something goes wrong.

A bad API client does the opposite.

It leaks implementation details. It makes developers memorize endpoint paths. It returns vague errors. It has inconsistent naming. It forces every project to rewrite the same pagination, authentication, retry, and response-handling code.

Developers notice.

And once they stop trusting your client library, they will either wrap it, avoid it, or call the API directly.

That is not where you want to be.

An API Client Is a Developer Experience Product

It is tempting to think of a client library as a thin technical layer around an API.

That mindset produces mediocre libraries.

An API client is not just a transport wrapper. It is part of the product experience. Its users are developers, and developers are still users. They get confused. They get impatient. They skim docs. They copy examples. They want sensible defaults. They want error messages that help. They want the library to feel obvious.

The best API clients respect that.

They do not make the developer think about HTTP unless HTTP actually matters. They do not require the developer to remember whether the endpoint is /users/{id}/subscriptions, /subscriptions/user/{id}, or /v2/accounts/{id}/billing/subscription.

They expose the concept directly:

const subscription = await client.users.getSubscription(userId);

That is the job of a client library: turn remote API behavior into code that feels local, predictable, and hard to misuse.

Start With the Shape Developers Want

The worst way to design an API client is to blindly mirror the HTTP API.

Sometimes that is fine. Often it is lazy.

A REST API is designed around resources, URLs, status codes, and network behavior. A client library is designed around code. Those are related, but they are not the same thing.

A developer using your library should not have to think in endpoint names first. They should think in product concepts.

Instead of this:

await client.request("POST", "/v1/customers/123/payment_methods", {
body: paymentMethod
});

Prefer this:

await client.customers.addPaymentMethod("123", paymentMethod);

The second version communicates intent. It is easier to autocomplete. It is easier to read in application code. It gives the library room to handle details like serialization, retries, idempotency keys, and error mapping.

The API may be HTTP underneath.

The client should feel like a proper library.

Make the First Five Minutes Excellent

Developers judge libraries fast.

Before they read your full documentation, they want to know a few things:

Can I install it easily?

Can I authenticate quickly?

Can I make one successful request?

Can I understand the response?

Can I handle an error?

Your client should make that first path painfully obvious.

A great quickstart matters more than a massive reference document. Give developers a working example that looks like real code, not a toy snippet with half the important details missing.

For example:

import { HarborClient } from "@harbor/client";
const client = new HarborClient({
apiKey: process.env.HARBOR_API_KEY
});
const collection = await client.collections.create({
name: "Production API",
description: "Requests used by the production team"
});
console.log(collection.id);

That is the moment where the developer decides whether the library feels clean or annoying.

Do not waste that moment.

Use the Language Naturally

A good JavaScript library should feel like JavaScript. A good Python library should feel like Python. A good Go library should feel like Go.

This sounds obvious, but many generated clients fail here. They expose awkward names, strange parameter objects, inconsistent casing, or patterns that technically work but feel foreign in the language.

In TypeScript, developers expect strong types, helpful autocomplete, clean async behavior, and predictable object shapes.

In Python, developers expect readable method names, keyword arguments, and exceptions that feel natural.

In Go, developers expect context support, explicit errors, useful structs, and minimal magic.

Do not force every language into the same shape just because your OpenAPI generator can do it.

Generated clients can be a useful starting point, but the final library should feel hand-finished. The extra effort shows.

Types Are Documentation

If your client is written for a typed ecosystem, the types are not just compiler decoration. They are part of the user interface.

Good types teach the developer how to use the library.

They show required fields, optional fields, return shapes, allowed string values, pagination metadata, and error structures. They reduce trips to the docs. They make autocomplete useful.

Bad types are worse than no types.

Avoid vague escape hatches like this unless there is truly no alternative:

createUser(data: any): Promise<any>

That tells the developer nothing.

Prefer this:

createUser(data: CreateUserInput): Promise<User>

And make those types meaningful:

type CreateUserInput = {
email: string;
name?: string;
role?: "admin" | "member" | "viewer";
};

Now the editor becomes part of the documentation.

That is a huge win.

Design Errors Carefully

Error handling is where many API clients fall apart.

Some return raw HTTP responses. Some throw generic errors. Some bury useful information three levels deep. Some behave differently depending on which endpoint failed.

Developers should not have to reverse-engineer your error system.

A good client library should provide consistent, structured errors:

try {
await client.users.create({
email: "not-an-email"
});
} catch (error) {
if (error instanceof HarborApiError) {
console.log(error.statusCode);
console.log(error.code);
console.log(error.message);
console.log(error.requestId);
}
}

The error should include enough information to debug the problem:

  • HTTP status code
  • API error code
  • Human-readable message
  • Request ID
  • Field-level validation details, when available
  • Whether the error is retryable, when possible

Do not just throw Error: Request failed.

That is useless.

When something breaks in production, the developer using your library needs enough information to understand what happened and what to do next.

Do Not Hide the Escape Hatch

A client library should make common things easy, but it should not make uncommon things impossible.

There will always be edge cases. New API endpoints may exist before the library is updated. Advanced users may need custom headers, raw responses, request hooks, or lower-level control.

Give them a clean escape hatch.

For example:

await client.request({
method: "POST",
path: "/v1/experimental/widgets",
body: {
name: "Test Widget"
}
});

This does not mean every developer should use the raw request method. Most should not. But its existence prevents the library from becoming a cage.

Good abstraction is helpful.

Forced abstraction is irritating.

Handle Pagination Like You Care

Pagination is one of those things every API has and every developer gets tired of rewriting.

A quality client should make pagination pleasant.

Do not force developers to manually loop through cursors for common use cases unless they need that control.

Give them options.

A simple list method:

const users = await client.users.list({
limit: 50
});

A manual pagination flow:

const page = await client.users.list({
cursor: nextCursor,
limit: 50
});

And, when the language supports it, an iterator:

for await (const user of client.users.listAll()) {
console.log(user.email);
}

That last version is the one developers will love when they need to process everything.

Pagination is not just an API feature. It is a usability problem. Solve it once inside the client so every application does not have to solve it again.

Sensible Defaults Beat Endless Configuration

Configuration is necessary.

Too much configuration is a smell.

A developer should not need to provide fifteen options just to make the first request. Start with sensible defaults:

  • Default API base URL
  • Reasonable timeout
  • JSON request and response handling
  • Standard retry behavior for safe retryable failures
  • Clear user agent
  • Environment-friendly authentication
  • Good error parsing

Then allow overrides for people who need them.

For example:

const client = new HarborClient({
apiKey: process.env.HARBOR_API_KEY,
timeoutMs: 10_000
});

That is enough for most users.

Advanced users can go deeper:

const client = new HarborClient({
apiKey: process.env.HARBOR_API_KEY,
baseUrl: "https://api.internal.example.com",
timeoutMs: 30_000,
retries: 3,
headers: {
"X-App-Version": "1.4.2"
}
});

The basic path should stay simple.

The advanced path should stay possible.

Respect Timeouts, Retries, and Idempotency

Network calls fail.

That is not an edge case. That is reality.

An API client should have a serious answer for timeouts, retries, cancellation, and idempotency.

Requests should not hang forever. Retry behavior should be conservative and predictable. The client should not blindly retry unsafe operations that might create duplicate records or trigger duplicate actions.

For read requests, retries are often reasonable.

For write requests, retries require more care. If the API supports idempotency keys, the client can make this much safer:

await client.payments.create(
{
amount: 5000,
currency: "USD"
},
{
idempotencyKey: "order_123_payment"
}
);

A developer should not have to become a distributed systems expert just to use your library safely.

The client cannot remove every network problem, but it can prevent the obvious ones from becoming application bugs.

Make Authentication Hard to Misuse

Authentication is one of the first things developers touch, so the client should make it clear and safe.

Bad authentication design looks like this:

client.setHeader("Authorization", "Bearer " + token);

That works, but it pushes too much responsibility onto the developer.

Prefer something explicit:

const client = new HarborClient({
apiKey: process.env.HARBOR_API_KEY
});

Or, for OAuth-style flows:

const client = new HarborClient({
accessToken
});

Avoid encouraging developers to hardcode secrets in examples. Use environment variables in documentation. Make token refresh behavior clear. If the client can help with refresh safely, provide that support. If it cannot, explain what the developer is expected to do.

Security problems often begin as convenience shortcuts in sample code.

Do not teach bad habits.

Naming Matters More Than You Think

Names are the surface area of your client library.

Once developers start using them, they become hard to change. Sloppy naming creates long-term friction.

Use names that match the domain, not names that expose internal implementation details.

Prefer:

client.collections.share(collectionId, teamId);

Over:

client.collectionPermissions.createMapping(collectionId, teamId);

Maybe the backend stores a permission mapping. The developer probably does not care. They care that they are sharing a collection with a team.

Good names reduce mental translation.

Also be consistent. If you use list, get, create, update, and delete for one resource, do not switch to fetch, retrieve, make, modify, and remove for another unless there is a strong reason.

Consistency is not boring.

Consistency is professional.

Avoid Making Developers Parse Raw Responses

Sometimes client libraries return the raw HTTP response and call it a day.

That is not enough.

Most developers want the useful data. They do not want to manually check status codes, parse JSON, and dig through transport details for every normal call.

This is annoying:

const response = await client.users.create(data);
if (response.status === 201) {
const body = await response.json();
console.log(body.data.id);
}

This is better:

const user = await client.users.create(data);
console.log(user.id);

That does not mean raw responses are never useful. They are. Headers, rate-limit details, and request IDs can matter.

So expose them deliberately:

const result = await client.users.create(data, {
includeResponse: true
});
console.log(result.data.id);
console.log(result.response.headers.get("x-request-id"));

Give the common path the cleanest API.

Give the advanced path enough control.

Documentation Should Match Real Usage

API client documentation should be full of real usage patterns.

Not just method signatures.

Show developers how to:

  • Initialize the client
  • Authenticate
  • Create a resource
  • List resources
  • Handle pagination
  • Catch errors
  • Configure timeouts
  • Use retries
  • Upload files
  • Work with webhooks
  • Test code that uses the client

Examples should look like code someone would actually put in an app.

Do not write docs that assume the developer already knows the answer. The whole point is to reduce uncertainty.

Also keep the docs close to the code. If the library changes and the docs lag behind, trust erodes quickly.

A wrong example is worse than no example because it wastes the developer’s time while pretending to help.

Testing the Client Is Not Optional

A client library needs its own tests.

Not just tests for the API.

The client has behavior: serialization, deserialization, authentication, retries, error mapping, pagination, file uploads, streaming, cancellation, and configuration.

Those things break.

Tests should cover both happy paths and ugly paths. Mock the transport layer. Test malformed responses. Test failed requests. Test retry behavior. Test that errors contain the right data. Test that optional fields are handled correctly.

Also consider contract tests against a real or sandbox API.

A client library is a promise. Tests help you keep it.

Version Carefully

Breaking changes in API clients are especially painful because they spread into other people’s codebases.

Do not rename methods casually. Do not change return shapes without a migration path. Do not remove fields just because the underlying API changed.

Use semantic versioning responsibly. Deprecate old methods before removing them. Provide clear migration guides.

Bad versioning teaches developers not to upgrade.

Once that happens, you end up supporting ancient versions forever because users are afraid the latest one will break everything.

A stable client library earns trust over time.

Logs and Debugging Help Matter

When an API call fails, developers need visibility.

A good client should make debugging possible without making normal usage noisy.

Consider supporting a debug mode:

const client = new HarborClient({
apiKey: process.env.HARBOR_API_KEY,
debug: true
});

Or a logger hook:

const client = new HarborClient({
apiKey: process.env.HARBOR_API_KEY,
logger: console
});

Be careful not to log secrets. Redact authorization headers, tokens, cookies, and sensitive payloads by default.

Debugging tools should help developers understand what happened without creating security risks.

That balance matters.

Make the Library Feel Maintained

Developers can tell when a library feels abandoned.

Old dependencies. Broken examples. Unanswered issues. No changelog. No recent release. No support for modern runtimes. Incomplete TypeScript types. Installation warnings. Deprecated packages.

These details damage confidence before the developer writes a single line of code.

A loved API client feels alive.

That does not mean constant churn. It means steady maintenance. Clear changelogs. Compatibility notes. Bug fixes. Runtime support. Honest documentation.

If the API is important, the client should be treated as important too.

Final Thoughts

Writing an API client that developers love is not about wrapping endpoints as quickly as possible.

It is about reducing friction.

The library should make the API feel natural, reliable, and safe in the developer’s language of choice. It should provide strong types where appropriate, helpful errors, clean pagination, sensible defaults, safe authentication, careful retries, useful documentation, and escape hatches for advanced cases.

The best API clients do not make developers think, “I am calling an HTTP endpoint.”

They make developers think, “This library does exactly what I expected.”

That feeling is not accidental.

It comes from caring about the details.

Posted in

Leave a Reply

Discover more from HarborClient

Subscribe now to keep reading and get access to the full archive.

Continue reading