API Design
Rebuilding Resend's PHP SDK
I rebuilt part of Resend's official PHP SDK on sdk-toolbox and ran both against the same failures. The rebuild handles what goes wrong better. The official one is still better to use.
Nov 202615 min read
When I wrote Most of Your SDK Has Already Been Designed, Too, I walked through sdk-toolbox one feature at a time: retries that follow RFC 9110, Retry-After, idempotency keys, rate-limit headers, webhook signatures. Every example in that article was an API I’d invented for the purpose, and that’s been bothering me a little bit since. It’s easy to make a feature look good against an API that was designed to show it off.
So I wanted to put it next to a real SDK that people actually use, rebuild part of it on the toolbox, and give both of them the same bad day. The SDK I picked was Resend’s.
Why Resend
I looked through about twenty official PHP SDKs before settling on this one, and the reason I picked it is that it’s good. resend/resend-php gets something like two and a half million installs a month, it had a release just last week, and it’s well made: a tidy service layer, a proper Pest test suite with architecture tests, idempotency keys, and its own webhook verification. If I’d rebuilt an SDK that hadn’t seen a commit in three years and declared victory, you’d have learned nothing from it, and neither would I.
Resend’s API is also a good fit for the comparison, because it actually tells clients how to behave. It sends ratelimit-limit, ratelimit-remaining and ratelimit-reset on every response, plus retry-after when you’ve gone over. It takes an Idempotency-Key on sends, keeps it for 24 hours, and doesn’t store a failed request, so a retry with the same key is safe. Webhooks are signed through Svix. It pages lists with after and has_more. And it publishes an OpenAPI spec under MIT, which meant I could build against it and write contract tests that fail when the two disagree.
The code for all of this is on GitHub as sdk-toolbox-resend. It’s code to read alongside this article, not a package, and it isn’t made by or affiliated with Resend. If you’re sending email from PHP, use theirs.
What the official SDK does well
Starting with it is about as easy as it gets:
$resend = Resend::client('re_xxxxxxxxx');
$resend->emails->send([ 'from' => 'Acme <onboarding@resend.dev>', 'to' => ['delivered@resend.dev'], 'subject' => 'hello world', 'html' => '<p>it works!</p>',]);That’s the PHP example on Resend’s own send-email page, and it’s the same on every page of their API reference. The array is the API’s JSON, so the docs, the curl examples and your code all read exactly the same, and there’s nothing to translate.
Under that, it’s a small and readable codebase. A client hands out service objects, a transporter does the HTTP, and value objects wrap the API key, the base URI and the headers. Its response handling is careful too: it refuses a non-JSON or empty body with a clear exception rather than a json_decode warning, and its webhook check does the things people forget, like the timestamp tolerance, more than one signature in the header, and hash_equals for the comparison.
Where it stops
Reading through the code, almost everything it leaves out sits in one place, which is what happens when a request doesn’t go to plan. Guzzle is hard-required, and while the transporter takes any PSR-18 client, Resend::client() builds its own Guzzle client, so using anything else means wiring the transporter up by hand. Nothing retries. The rate-limit headers the API sends on every response are never read, because the transporter hands back the decoded body and drops the headers. A send only carries an idempotency key if you pass one as an option. Results are attribute bags behind __get, with @property docblocks for the editor but nothing a static analyser can hold you to, and lists come back one page at a time.
There was one more thing I was fairly sure about from reading the code, and I was wrong. The transporter only reads Resend’s error body when it catches Guzzle’s ClientException, which is the 4xx one. A 5xx raises Guzzle’s ServerException instead, which isn’t a ClientException, so it looked to me as if a 500 from Resend would lose its error message and come back as a generic transport failure. I’d written that into my notes as a finding.
Then I wrote the test, and the official SDK kept the error body perfectly well. The reason is in Guzzle itself: sendRequest(), the PSR-18 method, turns http_errors off, because PSR-18 says a client mustn’t throw on a status. So nothing ever throws on a 4xx or a 5xx, every JSON error goes through the same check, and that catch (ClientException) branch can’t actually be reached. I’m glad I tested it before I said it in public, and that’s more or less why every claim in this article comes from a test rather than from reading.
Building the rebuild
The rebuild covers sending, getting and listing emails, plus webhooks, which is enough to run the comparison without pretending to be a full SDK. If you read the last article the shape will look familiar, so I’ll stick to the parts that are specific to Resend.
The connector is mostly configuration:
final class Resend extends Connector{ public const string BASE_URI = 'https://api.resend.com';
public function __construct( #[SensitiveParameter] string $apiKey, ?ClientInterface $http = null, RetryStrategy $retry = new ResendRetries, // clock, sleeper, throttle, logger... ) { parent::__construct( baseUri: self::BASE_URI, auth: new BearerToken($apiKey), http: $http, exceptions: ResendErrors::mapper(), retry: $retry, idempotencyKeys: IdempotencyKey::Bare, hydrator: new ValinorHydrator, ); }}IdempotencyKey::Bare is there because Resend takes the key as a plain string. The toolbox’s default follows the Idempotency-Key draft and sends it as a quoted structured-field string, which against Resend would have put the quotes inside the key. That’s a small thing, but it’s exactly the kind of small thing you only find by reading one real API’s docs rather than the spec.
Resend’s errors aren’t RFC 9457 problem details. They look like {"statusCode": 422, "name": "validation_error", "message": "..."}, so the status picks the exception class and a small mapper reads Resend’s fields into the problem each exception carries. A 404 is a NotFoundException, a 429 a TooManyRequestsException, and ResendError::of($e) gives you the name and message back.
Resend’s two 409s
This was the most interesting part of the build, and it’s something I’ve changed my mind about in the toolbox itself.
Sending an email implements the toolbox’s Idempotent interface, so every send carries a key, generated when you don’t give one, and every retry of that send carries the same key. The toolbox’s retry policy follows the Idempotency-Key draft, which says a 409 on a keyed request usually means the first request with that key is still running, so it’s worth trying again.
Resend does use 409 for that, as concurrent_idempotent_requests. But it also uses 409 for invalid_idempotent_request, which means you reused a key with a different body, and that one will fail the same way however many times you send it. The toolbox on its own retried it to the attempt limit before giving up. So the rebuild wraps the policy:
final readonly class ResendRetries implements RetryStrategy{ public const array FINAL = ['invalid_idempotent_request', 'invalid_idempotency_key'];
public function __construct( private RetryStrategy $policy = new RetryPolicy, ) {}
public function delay(RequestInterface $request, ?ResponseInterface $response, /* ... */): ?float { if ($response?->getStatusCode() === 409 && $this->final($response)) { return null; }
return $this->policy->delay($request, $response, /* ... */); }}final() reads the error’s name from the body. The lesson for me is that an API’s own error codes can matter more than its status codes, and a retry policy built purely on statuses will be wrong for some API somewhere, so it needs a way in for the SDK to say “not this one”.
Pages and webhooks
Pagination is one line, since Resend pages the way Stripe does, after the last ID, while has_more is true:
return $this->resend->paginate( new ListEmails, new StartingAfterPagination(parameter: 'after', limit: $perPage),);Webhooks are the toolbox’s Standard Webhooks verifier with Svix’s header prefix, reading the headers from the PSR-7 request and handing back the delivery ID to deduplicate on:
$webhooks = new ResendWebhooks(getenv('RESEND_WEBHOOK_SECRET'));
$event = $webhooks->verify($request);
$event->id; // the svix-id, so you can skip a delivery you've already handled$event->type; // "email.bounced"The same bad day, twice
The comparison tests give both SDKs exactly the same answers and record what each one does. The official SDK runs on a real Guzzle client with only its handler swapped for a queue, so Guzzle’s own behaviour is part of what’s tested, and it’s pinned at the version I tested, so the results stay true for anyone who clones the repo.
| What happened | Official SDK | Rebuild |
|---|---|---|
A 429 with Retry-After: 2 on a send | Fails at once, and the wait isn’t exposed | Waits two seconds, sends again, succeeds |
| A 503 on a send | Fails, and no idempotency key was sent | Sends again with the same key, so the email goes once |
| A 500 with Resend’s JSON error | Keeps the error | Keeps the error |
| A 502 from a proxy, as an HTML page | Reports a body it couldn’t read, with no status | A 502 ServerException, and a GET is retried |
| A webhook with headers in their original case | “Missing required headers” | Verified |
| A webhook with a PSR-7 request’s headers | A TypeError | Verified |
The 502 row is the one I’d want to know about if I ran this in production. When a load balancer in front of Resend has a bad moment and answers with an HTML error page, the official SDK raises UnserializableResponse with a code of zero, so a gateway outage looks exactly like a malformed response, and there’s no status to decide whether to try again.
The webhook rows are about what you pass in. The official verify() takes the payload, an array of headers and the secret, and it looks up $headers['svix-id'] directly. If your framework hands you headers in the case they arrived in, that fails as missing. If you pass a PSR-7 request’s getHeaders(), where every value is an array, you get a TypeError from deep inside the timestamp check. The README doesn’t cover webhook verification at all, so nothing tells you which shape it wants. Both SDKs do reject a delivery replayed ten minutes later, and both accept the same delivery twice inside the tolerance, so deduplicating on the ID is your job either way.
The numbers, and they don’t all go my way
I installed each SDK into an empty Composer project and measured what came with it.
| Official SDK | Rebuild with Guzzle | |
|---|---|---|
| Packages installed | 10 | 16 |
| Installed size | about 2.7 MB | about 6 MB |
| PHP versions | 8.1 and up | 8.3 to 8.5 |
I’d half expected the PSR-18 rebuild to be the lighter one, and it’s more than twice the size. Most of that is Valinor, the hydrator that turns JSON into typed objects, at over 2 MB on its own. Swapping Guzzle for Symfony’s HTTP client brings it down to about 5 MB, which is still well over the official SDK. “No Guzzle” gets you a choice of client. It doesn’t get you a smaller install, and in a Laravel app, which already has Guzzle, the official SDK adds almost nothing.
The PHP range is Valinor too. Its current release declares support up to PHP 8.5, so with 8.6 due next week the rebuild won’t install on it until Valinor catches up. I’ve left an 8.6 job in the repo’s CI that’s allowed to fail, so you can watch that happen. A typed hydrator is a dependency with its own release cycle, and your users inherit it.
Then there’s the first run, which surprised me the most. In a project with no HTTP client, composer require on the rebuild succeeds, because php-http/discovery declares that it provides the PSR-18 interfaces. Then the first new Resend(...) throws:
No PSR-18 clients found. Make sure to install a package providing "psr/http-client-implementation".Discovery’s Composer plugin would install a client for you, but only if you allow the plugin when Composer asks, and a non-interactive install in CI or a Docker build never asks. The official SDK works the moment it’s installed.
And per endpoint, typing everything costs a lot of code. The official SDK averages under ten lines of code per service method across about 140 of them, because most are one-line wrappers like send(array $parameters). Each of the rebuild’s request classes is somewhere between 25 and 40 lines, and that’s before the typed result class and the typed input class that go with it.
Where the official SDK is better
Once I had all of this in front of me, the honest summary was that the rebuild is better at what goes wrong, and the official SDK is better at what goes right. That second list deserves more than a footnote, because it’s where most developers spend most of their time.
The docs are written for it, so the first email is a copy and paste. Its arrays keep up with the API without a release: a new request field works the day Resend ships it. My typed NewEmail already lags today’s spec, with no attachments and no templates, so with the typed API you simply can’t send either. New response fields are readable straight away on the official resources too, while my hydrator ignores keys it doesn’t know, deliberately, so a new field can’t break it, which also means a new field is invisible until somebody adds a property.
It covers the whole API, and the rebuild covers three endpoints. Laravel’s own mail manager builds its Resend transport with Resend::client(), and resend/resend-laravel builds on it, so a Laravel developer gets the official SDK whether they ask for it or not. There’s one exception class to catch, and matching on Resend’s own error names is arguably easier than knowing which class a daily_quota_exceeded arrives as (it’s a 429). And it does nothing you didn’t ask for. The rebuild retries by default, and honouring Retry-After can mean a send waits for up to a minute. Inside a queued job that’s what you want. Inside a controller, with a user watching a spinner, it’s a surprise, and the official SDK failing fast and leaving the decision to you is a perfectly reasonable design.
So the official SDK isn’t the lazy one here. It’s made a different set of choices, and they’re the right choices for getting someone from composer require to a sent email with as little in the way as possible.
Having both
What I didn’t want to end on was “pick one”, because there isn’t really a reason the reliability layer and the array-shaped API have to be in tension. So I added an untyped way in to the rebuild:
// The array from Resend's docs, as it is.$resend->emails()->send([ 'from' => 'Acme <onboarding@resend.dev>', 'to' => ['delivered@resend.dev'], 'subject' => 'hello world', 'html' => '<p>it works!</p>', 'attachments' => [['filename' => 'invoice.pdf', 'content' => $pdf]],]);
// Any endpoint, every field, known or not.$email = $resend->get('/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c');$email['tags'];The array send still gets a generated idempotency key, still retries a 503 with that same key, and still waits out a 429. get(), post(), patch() and delete() go through the same retries, throttle and exceptions as the typed resources, and hand back the whole decoded body. post() only sends an idempotency key when you give it one, since a key is a promise, and only the endpoints Resend documents as idempotent can keep it. The whole thing was under a hundred lines of code.
That’s the version I’d actually want to use. Typed resources where the shape is settled and I want PHPStan’s help, arrays where I’m following the docs or the SDK hasn’t caught up yet, and the same behaviour underneath both when something goes wrong.
What I’m taking from it
If you’re sending email with Resend from PHP, use their SDK, and I mean that. If retries matter to you, wrap your sends in your own retry with an idempotency key you choose, since the API supports it and the SDK will pass it through.
If you build SDKs, the reliability layer is the part that’s almost always missing, and it’s the part that decides whether your users have a quiet night when the API has a bad one. But it can’t cost the developer experience, because the developer experience is what gets your SDK chosen in the first place, and nobody discovers your excellent 502 handling if they gave up at the install.
For sdk-toolbox, that leaves me with two things to fix. The first run has to work, which means failing at install time rather than at the first request, or shipping a sensible default client. And an SDK built on it needs an untyped escape hatch from day one, so typed models never stop someone using a field the SDK hasn’t heard of yet.
Related
More in API Design
You Can't Deprecate a Field With a Header
RFC 9745's Deprecation header covers whole resources, so a field changing shape has no HTTP signal. Where that deprecation goes instead, and where the header is exactly right.
Oct 2026 · 7 min read
API DesignBoth Ends of the Wire
I built a small CRM API on api-toolbox and its SDK on sdk-toolbox, in one repository, tested against each other. My own two libraries disagreed within a day.
Oct 2026 · 8 min read
API DesignFixing a Bug Can Be a Breaking Change
Your consumers built against your behaviour, not your documentation. Correcting a bug they worked around is an outage you shipped on purpose.
Oct 2026 · 7 min read
Same subject
In other formats
sdk-toolbox
Build PHP SDKs on PSR-18, typed end to end, with the HTTP standards an API client should honour.
API Design
TalkArtisanal API Platforms: Crafting Developer-First Experiences
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