DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Designing APIs for Developer Experience: What Makes SDKs a Joy to Use

What Makes a Developer API Good?

Stipe's API is consistently cited as one of the best in the industry. Twilio, Clerk, and Resend get similar praise. What do they have in common?

It's not the features—it's the experience of using the features.

Principle 1: Predictability Over Cleverness

// Unpredictable: inconsistent response shapes
const user = await api.users.get(id);   // returns { data: User }
const posts = await api.posts.list();   // returns User[] (no wrapper)

// Predictable: same shape everywhere
const user = await api.users.get(id);   // returns User
const posts = await api.posts.list();   // returns Post[]
// OR always wrap:
const { data: user } = await api.users.get(id);
const { data: posts } = await api.posts.list();
Enter fullscreen mode Exit fullscreen mode

Consistency is more important than elegance. Developers build mental models. Don't break them.

Principle 2: Errors That Tell You What to Do

// Bad error
throw new Error('Invalid input');

// Good error
throw new ApiError({
  code: 'VALIDATION_ERROR',
  message: 'The email field is required',
  field: 'email',
  docs: 'https://docs.example.com/api/users#create',
  requestId: 'req_abc123', // for support
});
Enter fullscreen mode Exit fullscreen mode
{
  "error": {
    "code": "STRIPE_CARD_DECLINED",
    "message": "Your card was declined",
    "decline_code": "insufficient_funds",
    "doc_url": "https://stripe.com/docs/error-codes/card-declined",
    "charge": "ch_abc123"
  }
}
Enter fullscreen mode Exit fullscreen mode

Stripe's errors tell you: what happened, why, what the user should do, and where to learn more. Copy this.

Principle 3: Resource-Oriented URLs

# Good: resources + HTTP verbs
GET    /users           → list users
POST   /users           → create user
GET    /users/:id       → get user
PATCH  /users/:id       → update user
DELETE /users/:id       → delete user

GET    /users/:id/posts → user's posts

# Bad: verb-in-URL (RPC style)
GET  /getUser
POST /createUser
POST /deleteUser
POST /getUserPosts
Enter fullscreen mode Exit fullscreen mode

REST conventions mean developers can guess your API shape before reading docs.

Principle 4: Sensible Defaults, Full Control

// All parameters optional — works with minimal input
const posts = await api.posts.list();
// returns 20 posts, sorted by createdAt desc, active only

// Full control when needed
const posts = await api.posts.list({
  limit: 100,
  offset: 200,
  sort: 'title',
  direction: 'asc',
  status: 'draft',
  userId: 'user_123',
});
Enter fullscreen mode Exit fullscreen mode

Principle 5: Idempotency Keys

Networks fail. Clients retry. Without idempotency, retries create duplicates.

// Idempotency key prevents duplicate charges
await stripe.charges.create(
  { amount: 2000, currency: 'usd', source: 'tok_visa' },
  { idempotencyKey: `charge_${orderId}` } // safe to retry
);
Enter fullscreen mode Exit fullscreen mode

For your own API:

app.post('/api/payments', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (idempotencyKey) {
    const cached = await redis.get(`idempotency:${idempotencyKey}`);
    if (cached) return res.json(JSON.parse(cached)); // return same response
  }

  const result = await processPayment(req.body);

  if (idempotencyKey) {
    await redis.setEx(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(result));
  }

  res.json(result);
});
Enter fullscreen mode Exit fullscreen mode

Principle 6: Pagination That Works

// Cursor-based (preferred for large datasets)
// GET /posts?limit=20&after=post_xyz
{
  "data": [...],
  "meta": {
    "hasMore": true,
    "nextCursor": "post_abc",
    "total": null // omit for large tables
  }
}

// Offset-based (simpler, but skip/take has performance issues at scale)
// GET /posts?page=5&limit=20
{
  "data": [...],
  "meta": {
    "page": 5,
    "limit": 20,
    "total": 1847,
    "pages": 93
  }
}
Enter fullscreen mode Exit fullscreen mode

Principle 7: Versioning From Day One

# URL versioning (simple, explicit)
https://api.example.com/v1/users
https://api.example.com/v2/users

# Header versioning (cleaner URLs)
API-Version: 2024-01-01
Enter fullscreen mode Exit fullscreen mode
// Express version routing
const v1 = express.Router();
const v2 = express.Router();

v1.get('/users', v1UserHandler);
v2.get('/users', v2UserHandler); // different response shape

app.use('/v1', v1);
app.use('/v2', v2);
Enter fullscreen mode Exit fullscreen mode

TypeScript SDK Generation

Document your API with OpenAPI, generate typed clients:

# Generate TypeScript client from OpenAPI spec
npx openapi-typescript-codegen \
  --input https://api.example.com/openapi.json \
  --output ./src/api-client
Enter fullscreen mode Exit fullscreen mode

Now your API users get autocomplete and type checking for free.

The best DX is one where developers succeed without reading docs. Work backwards from that goal.


RESTful API patterns, versioning, error handling, and OpenAPI docs: Whoff Agents AI SaaS Starter Kit.

Top comments (0)