API Design
Most of Your SDK Has Already Been Designed, Too
Retries, rate limits, pagination, lost updates: the API already tells your client what to do. sdk-toolbox makes a PHP SDK listen, one RFC at a time.
Oct 202619 min read
A couple of weeks ago I wrote Most of Your API Has Already Been Designed, arguing that an API shouldn’t invent its own error format, retry header or pagination envelope, because the IETF already wrote each of them down years ago. There’s a gap in that argument I kept coming back to afterwards, on the other end of the wire: a standard only pays off when the client actually reads it.
Your API can send a perfect Retry-After header, and if the SDK talking to it retries on a fixed one-second loop anyway, you’ve gained nothing. It can answer errors as RFC 9457 problem details, and if the SDK just throws “Request failed with status 422”, the problem’s type goes straight in the bin.
I’ve written a fair few SDKs over the years: for Sevalla, Ollama, Lemon Squeezy, OpenLibrary, Envoyer and, most recently, einvoicing.dev. I wrote about Saloon back in 2022, and I’ve had two goes at a shared foundation of my own, php-sdk and sdk-tools. Every one of those SDKs needed the same handful of things built again: retries, error handling, pagination, authentication, and a way to test it all without hitting the real API.
So I built sdk-toolbox, a PHP library for building SDKs where every one of those things follows a published standard. What follows is the order you’d build an SDK in, with the places you wouldn’t want each part.
Starting with the shape
If you’ve used Saloon, this first bit will look familiar, and that’s on purpose. Saloon got the shape right. You describe the API once with a connector:
use JustSteveKing\SdkToolbox\Auth\BearerToken;use JustSteveKing\SdkToolbox\Connector;use Psr\Http\Client\ClientInterface;
final class Acme extends Connector{ public function __construct(#[\SensitiveParameter] string $token, ?ClientInterface $http = null) { parent::__construct( baseUri: 'https://api.acme.test/v2', auth: new BearerToken($token), http: $http, userAgent: 'acme-php/1.0', ); }}Then each endpoint gets a small request class, which says what to send and what comes back:
use JustSteveKing\SdkToolbox\Http\Method;use JustSteveKing\SdkToolbox\Http\Response;use JustSteveKing\SdkToolbox\Requests\Request;
/** @implements Request<Project> */final readonly class GetProject implements Request{ public function __construct(private string $id) {}
public function method(): Method { return Method::Get; }
public function path(): string { return '/projects/'.rawurlencode($this->id); }
public function result(Response $response): Project { return Project::fromArray($response->json()); }}And you send it:
$project = (new Acme($token))->send(new GetProject('prj_1'));The @implements Request<Project> line is what tells PHPStan that send() returns a Project here. In Saloon, the DTO you get back from a response is typed mixed, so you end up adding @var annotations at every call site to tell the analyser something it should already know. Here, the request carries its own result type, and the connector passes it straight through.
Two decisions sit underneath it.
The first is that the toolbox doesn’t bring an HTTP client. It speaks PSR-18, so an application hands it whatever client it already has, whether that’s Symfony’s, Guzzle, or something its framework provides. If it hands over nothing, php-http/discovery finds one that’s installed. An SDK that requires its own Guzzle is an SDK that can end up fighting its users’ dependency tree over a version constraint, and this one can’t.
The second is that request paths can’t leave the base URI. /projects under https://api.acme.test/v2 goes to /v2/projects, which is what you’d expect. But what about a path of //elsewhere.example, or an absolute URL, or a .. segment someone percent-encoded to slip past a naive check? All refused, before anything is sent. Someone using your SDK might pass an ID straight from a form field, and that ID should never be able to redirect a request, and its credentials, to another host.
Retries, and when not to
So what happens when Acme answers with a 503?
Every SDK retries eventually, and the most common way to get it wrong is to retry on any error. For a GET, that’s harmless. For a POST that came back with a 502, it’s how a customer gets charged twice. A 502 from a proxy tells you nothing about whether the request behind it was applied. It might have been. You don’t know.
RFC 9110 settled this a long time ago. Section 9.2.2 says a client shouldn’t automatically retry a request that isn’t idempotent, unless it knows the request wasn’t applied. So the toolbox’s retries, which are on by default, follow exactly that:
- A 408, 425, 429 or 503 is retried for any method, because each of those means the server didn’t process the request.
- A 500, 502, 504, or no response at all, is only retried for a method that’s safe to repeat: GET, HEAD, OPTIONS, PUT and DELETE.
- Anything else, a 400, a 401, a 404 or a 422, is never retried, because waiting won’t fix it.
Then there’s the question of how long to wait. The response gets the first say: Retry-After is honoured, whether it’s a number of seconds or an HTTP date, in any of the three date formats the RFC says a client has to accept. Without one, the wait is random, up to a ceiling that doubles each time. The randomness is deliberate: if a thousand clients fail at the same moment and all retry after exactly one second, they aim a second spike at a server that was already struggling.
A Retry-After of more than a minute gives up rather than sleeping, and you can cap the whole sequence with a deadline:
retry: RetryPolicy::standard()->withMaxAttempts(5)->withDeadline(30.0),When wouldn’t you want the standard rules? When the API has rules of its own. One API I’ve been looking at sends a 429 both for “you’re going too fast” and for “you’ve used up your plan”, and only the first of those clears if you wait. That’s what RetryStrategy is for: a small class of your own that makes that one decision, and hands everything else back to the standard policy.
Making a POST safe to retry
If a POST that hits a 502 isn’t retried, does that make creating things fragile?
It doesn’t have to be. In the api-toolbox article I covered the server side of the Idempotency-Key header: the server remembers the first response for a key, and replays it if the same key turns up again. That only works if the client sends the same key on every attempt, and that’s exactly the bit that’s easy to get wrong if your SDK generates a fresh key inside its retry loop.
In the toolbox, a request opts in by implementing Idempotent:
final readonly class CreateCharge implements HasBody, Idempotent, Request{ public function __construct(private int $amount, private ?string $orderId = null) {}
public function idempotencyKey(): ?string { return $this->orderId; // null means a random UUID }
// method(), path(), body(), result()...}The connector generates one key per send and keeps it for every retry of that send. And because the request now carries a key, the retry policy is happy to retry it after a 502, since the server can recognise a repeat. If you return a key of your own, like an order ID, you get one more thing for free: a retry after your process crashed and restarted reuses the same key too.
This is built on an Internet-Draft whose section on retries is still being argued over, so the feature is marked experimental. The draft also says the key should be a quoted string, while Stripe and plenty of others take it bare, so there’s an IdempotencyKey::Bare option for those.
Reading errors properly
Let’s say Acme does speak RFC 9457, and after the last article, I really hope more APIs do. What does it actually mean for a client to read a problem properly?
The RFC is surprisingly specific about it. type is the problem’s identifier, so that’s what you match on, and never the title or detail, which are written for humans and change whenever someone rewrites the copy. A relative type gets resolved against the request URI. And a member with the wrong JSON type is ignored rather than trusted, so a "status": "404" sent as a string simply gets dropped.
On top of that, send() throws a class per status: NotFoundException, UnprocessableContentException, TooManyRequestsException and the rest. Each one carries the parsed problem. Mapping the API’s own problem types onto your SDK’s exceptions takes one line on the connector:
exceptions: new ProblemTypes([ 'https://api.acme.test/problems/out-of-credit' => OutOfCredit::class,]),Plenty of APIs don’t speak RFC 9457 at all, of course, and send {"error": "..."} on every status. For those, you hand StatusExceptions a function that reads the API’s own shape into a problem, and every exception carries it, whatever the status:
exceptions: new StatusExceptions(problems: function (Response $response): ?ProblemDetails { $error = json_decode($response->body(), true)['error'] ?? null;
return is_string($error) ? new ProblemDetails(title: $error, status: $response->status()) : null;}),A 404 is still a NotFoundException, just with the API’s own message on it.
None of those exceptions holds the request. Each keeps a redacted snapshot of it instead, because the request carries the credentials, and exceptions have a habit of ending up in error trackers that half the company can read. The same goes for log lines, trace spans, var_dump() output, and even the error messages HTTP clients write themselves, which very often quote the full URL, API key and all.
Rate limits, before the 429
An API that’s about to rate-limit you will usually tell you first. The IETF’s RateLimit header fields draft gives that a standard shape:
RateLimit-Policy: "default";q=100;w=60RateLimit: "default";r=0;t=12Lots of APIs still send the older X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset instead, and the last of those is ambiguous. In some APIs, X-RateLimit-Reset means seconds from now. In GitHub’s, it’s a Unix timestamp. Nothing in the header tells you which. The toolbox reads both forms into a single typed RateLimit on every response, and a value large enough to be a timestamp gets read as one.
To act on it, give the connector a throttle. It stops sending once the quota is spent, until it resets:
throttle: new Throttle(maxWait: 30.0),If the reset is further away than you’re willing to wait, it throws without sending, rather than collecting 429s. Give it a PSR-16 store, and several workers sharing one quota all wait for the same reset.
The RateLimit draft expires on 24 November 2026 and hasn’t reached last call, so this is experimental as well. And if your API sends no rate-limit headers at all, the throttle simply does nothing, which is exactly how it should fail.
Every page, one at a time
Now, GET /members returns a page. But when did you last want only the first page? Callers nearly always want all of them, and the same request class gives you either:
foreach ($acme->paginate(new ListMembers, new LinkPagination) as $member) { // every member}LinkPagination follows RFC 8288’s Link: <...>; rel="next", which is how GitHub pages, parsed the way the RFC’s appendix lays out. There are strategies for the common conventions too: a cursor in the body, a next-page URL in the body (which Django REST Framework and Packagist both do), Stripe’s has_more and starting_after, offsets, and plain page numbers.
Only one page is ever in memory. There’s a test that walks 100,000 items across 2,000 pages and checks that memory stays flat, because “the paginator ate all the RAM on page 900” is a bug report I’d rather nobody ever has to write.
Every page is a full request. Retries, the throttle and authentication all apply to page 400 the same way they applied to page 1.
And a next link pointing outside the base URI gets refused, whether it came in a Link header or the body. That one’s easy to overlook. A next link comes from the API’s response, and a client that blindly follows it will send its credentials wherever it’s told.
Not losing someone’s change
Lost updates never throw an error. Two people load the same project. Both rename it. The second save quietly overwrites the first, and nobody ever finds out.
The api-toolbox article showed the server’s half of the fix: send an ETag, and check If-Match on writes. This is the client’s half:
$etag = $acme->response(new GetProject($id))->etag();
// in RenameProject, which implements Conditional:public function precondition(): ?Precondition{ return Precondition::ifMatch($this->etag);}If somebody changed the project in between, the API answers 412, and you get a PreconditionFailedException, which you can turn into “someone else just changed this” instead of losing their work. There’s one detail in the RFC that’s easy to miss, too. If-Match uses strong comparison, which means a weak ETag (W/"...") can never match anything. Send one, and every update fails. The toolbox refuses it up front instead.
Waiting on slow work
For an export or a report that takes a while, the api-toolbox article used 202 Accepted with a Location header, polled until the work is done, then a 303 to the result. From the client’s side, I wanted that to be a single call:
$export = $acme->await(new StartExport($filters), timeout: 300.0);It polls the monitor, waits as long as Retry-After says between polls, follows the 303, and hands the result to your request’s result(), just like send() would. If the API answers straight away instead, you just get the answer. And if the timeout hits first, the exception carries the monitor’s URI, because the work may well finish, and you’ll want to come back and check.
Caching the right way
Pass a PSR-16 cache to the connector, and it caches the way RFC 9111 says a private cache should. A fresh response comes back without a request at all. A stale one gets revalidated with If-None-Match, and a 304 brings back the body it already had. Vary is honoured, no-store is never stored, and a successful write to a URI throws its cached entry away.
The bit I’d pay the most attention to is that entries are keyed on the request’s credentials. Put a cache in front of a shared Redis without that, and one user can end up served another user’s response.
So when wouldn’t you turn it on? When the API sends no caching headers. The toolbox doesn’t guess at freshness, so a response without max-age or Expires is always checked with the server before it’s reused. That’s the conservative reading of the RFC, and it means the cache can never hand back something stale that the API didn’t say was safe to keep.
Hearing about deprecations
RFC 9745’s Deprecation header and RFC 8594’s Sunset tell a client an endpoint is on its way out, and when it stops working. Both are only useful if somebody notices them, so the toolbox reads them and calls a handler of your choice once per endpoint, not once per request, so a loop over a deprecated endpoint doesn’t flood your logs.
The setting I’d actually reach for is the strict one, in your SDK’s own test suite:
deprecations: Deprecations::strict(),The day the API starts marking an endpoint you use as deprecated, your tests go red, and you find out before your users do.
OAuth, without the footguns
Bearer tokens, Basic auth and API keys are one line each, so I won’t dwell on them. OAuth is where reading the RFCs really pays off, because it’s full of small rules that are easy to miss:
- When a client sends its ID and secret using HTTP Basic, RFC 6749 says to form-encode both first. Skip that, and a secret with a
+in it fails. - PKCE should be used even by confidential clients, as RFC 9700 now recommends, and only ever with S256.
- When the server issues a new refresh token, the old one has to be thrown away. And with a server that rotates them, a reply without one means the old one is already spent: send it again later, and the server reads it as a replay and revokes the lot.
rotating: truecovers that. - The device flow, which is how a CLI logs someone in, has exact polling rules: wait the interval, add five seconds whenever the server says
slow_down, and stop on anything else.
The toolbox handles all of those, plus one thing no RFC spells out: refreshing a token exactly once. Imagine ten workers that all find the same expired token at the same moment. Should they all refresh it? With refresh-token rotation, every one of them after the first would be holding a refresh token the server has already retired. So every refresh runs inside a lock, and the first thing it does once it has the lock is look again, in case another worker already did the job.
The token store keeps plain arrays and never calls unserialize(). If that sounds paranoid, Saloon’s v4 release fixed a remote code execution that came from exactly that.
Verifying webhooks
The users of your SDK often need to verify the webhooks the same API sends them, so the toolbox has verifiers for Standard Webhooks (both the HMAC and the Ed25519 scheme, and Svix’s svix- headers), the GitHub-style HMAC of the body, the timestamped HMACs Stripe, Slack and Ghost use, which refuse a delivery that’s too old, and RFC 9421 HTTP Message Signatures:
$id = (new StandardWebhooks('whsec_...'))->check($request); // the webhook-id, to drop repeatsHow these are tested matters as much as what they do. Each verifier is tested against the example its specification or sender publishes, wherever there is one. The RFC 9421 tests reproduce the RFC’s example signatures byte for byte. The Standard Webhooks test uses their official PHP library’s own vector, and Svix, Slack and GitHub each have their own documented example in there too. Stripe and Ghost don’t publish a secret alongside a signature, so theirs follow the steps each one documents. Every vector was fetched from its source and checked before it went in. The one time that rule slipped, with a PKCE verifier typed in from memory, the result was a test that failed against perfectly correct code.
Testing your SDK
Every SDK needs the same testing tools, so the toolbox ships them. FakeClient answers from a queue, and remembers what it was sent:
it('gets a project', function (): void { $http = new FakeClient(FakeResponse::json(['id' => 'prj_1', 'name' => 'Website']));
$project = (new Acme('test-token', $http))->send(new GetProject('prj_1'));
expect($project->name)->toBe('Website'); $http->assertSentTo('GET', '/v2/projects/prj_1');});When the queue runs out, it throws, so nothing in your test suite reaches the network by accident. FakeTime stands in for the clock and the sleeper together, which means a test of a retry with Retry-After: 30 finishes instantly, and can still assert that it waited thirty seconds.
Then there’s Recorder, for the part hand-written fakes are worst at: staying true to the real API. Run your tests once with SDK_FIXTURES=record, and it captures the real responses. From then on, it replays them. Before anything touches the disk, it masks the API key, the tokens, and anything else secret-looking in the headers, the query and the bodies, so the fixtures are safe to commit.
So, what should you turn on?
| You’re building… | Turn on |
|---|---|
| An SDK for any API | nothing: retries, redaction and the base URI rules are on by default |
| An SDK whose POSTs need retrying | Idempotent on those requests, if the API supports the header |
| A client for an API that rate-limits hard | the throttle, with a shared store if several workers share the quota |
| A client that reads the same data a lot | the HTTP cache, if the API sends max-age or ETags |
| A CLI that logs a user in | OAuth’s device flow |
| Anything you’ll maintain for years | Deprecations::strict() in your tests, and recorded fixtures |
What it won’t do
It doesn’t do concurrent requests. Saloon does, through Guzzle’s promises, so if you need a pool of parallel requests today, Saloon is the better choice, and I’d tell you to use it. It’s also at 0.x, so expect the odd change before 1.0.
The other real cost is the request classes. I moved einvoicing.dev’s SDK onto the toolbox to prove the whole thing on a real API, and its twelve endpoints came to about 470 lines of request classes, while the HTTP plumbing they replaced shrank by about 140. Its public API didn’t change, and 26 of its 28 tests passed with only the test helper changed. It gained behaviour and lost brevity, and I think that’s the right trade.
I’ve since built a fuller example against an API I don’t run: a PHP SDK for Todoist, as a demonstration rather than a package. It covers OAuth with Todoist’s rotating refresh tokens, webhook signatures, and models checked against Todoist’s own OpenAPI spec. Building it turned up a handful of things the toolbox couldn’t do yet, from comma-separated OAuth scopes to Todoist’s own error shape, and every one of them is in the toolbox now.
Your agent can build with it, too
Like api-toolbox, it ships a skill for AI agents that teaches them to build SDKs this way: one request class per endpoint, no hand-rolled retry loops, credentials only ever in an authenticator, and tests on the fake client. Laravel Boost picks it up on its own, and for anything else you copy it into your agent’s skills directory.
Both ends of the wire
The whole point of a standard is that both ends already agree. An API built with api-toolbox sends Retry-After, RateLimit, Link, ETags and problem details. An SDK built with sdk-toolbox reads every one of them. Neither side needs a documentation page explaining its own private version of any of it.
That’s what I mean by most of your SDK already having been designed. The decisions about when to retry, how long to wait, what an error means and where the next page is were made years ago, by people who thought about them far harder than any of us will on a deadline.
Related
More in API Design
An Example App That Needs Its Features
Flagpole is a feature flag service built on kit, where every HTTP standard is there because the domain needs it. Building it found four bugs that kit's own tests never caught.
Oct 2026 · 6 min read
API DesignA Laravel API Starter Kit That Keeps Its Promises
Starter kits are right on the day you install them and drift from then on. Kit now checks its own promises in CI, so the docs, spec, errors and dependencies stay true.
Oct 2026 · 12 min read
API DesignMost of Your API Has Already Been Designed
Error formats, retries, lost updates, webhook signing: the IETF has already answered them. api-toolbox brings those answers to Laravel, one RFC per feature.
Oct 2026 · 17 min read
Same subject
In other formats
http-slim
A slim psr compliant http client to provide better interoperability.
API Design
VideoLet's Build: PHP SDK
Join me live as I build a PHP SDK from scratch for the Sevalla's REST API! We’ll cover everything from authenticating requests to making clean, reusable API calls using modern PHP practices.
API Design