01One request, six languages
The send endpoint is POST /v1/emails on your own Worker. The request and response shapes are Resend’s exactly, which is not a marketing claim but a compatibility contract: if you already have resend installed, changing the base URL and the API key is the entire migration. Everything MailySend adds to the payload is additive, and their SDK ignores fields it does not know.
One POST, a bearer token, and a from address on a verified domain — anything else is refused with a 403 invalid_from_address before a single byte reaches a transport.
curl https://your-worker.workers.dev/v1/emails \ -H "Authorization: Bearer ms_test_…" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "It works", "html": "<p>First one.</p>" }'
Start with curl. It has no SDK version to be wrong about and its failure modes are visible. Use a ms_test_ key: the prefix scheme mirrors Stripe’s precisely so that the environment is legible in a log line or a screenshot, which is the whole point of having one — you should never have to open a dashboard to find out whether the key in a paste is live.
Go, PHP and Ruby need no package. The endpoint is a JSON POST with a bearer token; the Resend clients for those languages accept a base URL override, and any HTTP client in any language is four lines. If your language has a Resend SDK, point it here. If it does not, do not wait for one.
02Reading the response
A successful send returns a small object: an id, a created_at, and — only when it applies — a suppressed array. That is the whole payload, and each of the three is worth understanding before you build anything on top of it.
A 200 means accepted and spooled. Delivery, bounce and complaint all arrive later as events, so the field worth storing next to your order number is the id.
$ curl -s … /v1/emails | jq{"id": "email_2Nq8x…","created_at": "2026-09-11T09:31:04.118Z","suppressed": ["[email protected]"]}# suppressed is present only if something was dropped
| Field | Always there? | What it is for |
|---|---|---|
| id | Yes | The primary key of this message, minted before a provider is chosen. Store it. |
| created_at | Yes | The moment of acceptance, not of delivery. ISO 8601, UTC. |
| suppressed | Only when non-empty | The recipients that were dropped. Its presence is itself the signal that this send went to fewer people than you asked for. |
| provider_message_id | Not in this response | Recorded separately and queryable later, for when you open a support ticket with the provider. It is never the primary key of your mail. |
The id is yours, not the provider’s. It is minted in the synchronous half of the send path — before anything touches a network — which is the decision the entire multi-transport design rests on. Because the id exists before a provider is chosen, it survives a failover mid-send, a migration from SES to Cloudflare next year, and a provider that loses its own identifier.
- Authenticated you and reserved your idempotency key
- Resolved and checked the sending domain, then filtered the suppression list
- Resolved any schedule and minted the id
- Spooled the envelope and put it on a queue
- It has not opened an SMTP connection
- Delivery happens on the consumer, asynchronously, and reaches you as a delivered, bounced or complained event rather than as an HTTP status
- Any dashboard showing a green tick at this moment is showing you the wrong thing
An empty inbox is not always a delivery problem. If every recipient is suppressed the request is refused outright with a 403 recipient_suppressed rather than accepted — silently accepting a message with nobody to deliver it to would appear in your dashboard as a delivered email that nobody received, which is the worst of both worlds. Six weeks later, when a customer says they never got the receipt, the id stored beside your order number is the only key that answers “what happened to that message” quickly.
03Retrying safely
A network timeout is not an answer. Your request may have been fully processed and the response lost on the way back, or it may never have arrived. Without an idempotency key those two cases are indistinguishable, and the only two strategies available are both wrong: retry and risk sending a second password reset, or give up and risk sending none.
Send an Idempotency-Key header and a retry inside 24 hours returns the original response instead of doing the work again.
$ curl -H "Idempotency-Key: order-4821-receipt" … /v1/emails200 { "id": "email_2Nq8x…", "created_at": "…" }# connection drops. same key, same body, again:$ curl -H "Idempotency-Key: order-4821-receipt" … /v1/emails200 { "id": "email_2Nq8x…", "created_at": "…" }# one message. same id. nothing was sent twice.
Derive the key from the thing that must happen once — an order id, a signup id, a reset-token id — never from a random value generated at retry time, which would be a new key and therefore a new send. The reservation itself is a single INSERT … ON CONFLICT DO NOTHING, not a cache write: an eventually-consistent store lets two simultaneous retries both read “absent” and both proceed, which is exactly the race an idempotency key exists to close.
| Answer | What happened | What to do |
|---|---|---|
| 200 (replayed) | The key was seen before with this exact body, and the stored response is returned. | Nothing. The work was not repeated. |
| 409 concurrent_idempotent_requests | Another request with the same key is still in flight. | Wait, then retry. Hammering makes more of these, not fewer. |
| 400 invalid_idempotent_request | The key was used before with a different body — the request is hashed and compared, so a reused key is reported rather than quietly returning the wrong id. | Fix how the key is derived. This is almost always a bug on your side. |
The reservation row expires 24 hours after it is created.
04When you get a 200 and nothing arrives
The API accepted the message, you have an id, and the inbox is empty. This is the most common first-hour experience and it is almost never mysterious.
Work these four in order. Each is cheaper than the one after it, and checking the event stream first is how an afternoon disappears into logs when the actual problem was a domain sitting in pending.
A domain can exist, look right in the dashboard, and still be pending because one DNS record has not propagated or was pasted with the domain appended twice. Re-run verification and read which record failed; a send from an unverified domain is refused, so if you got an id this is not it — but a domain that verified and later broke is the single most common cause of a silent stop.
The API accepts and queues before any provider is contacted, so a bad SES key or a Cloudflare account without Email Sending enabled produces a perfectly healthy 200 and a failure on the consumer minutes later. Check the message status: queued that never becomes sent means the send worker could not authenticate.
If some recipients were dropped the response carried a suppressed array; if all of them were, the request was refused outright. A previous hard bounce or a complaint suppresses an address permanently and deliberately — check the suppressions screen before assuming a delivery problem, and remove the entry only if you know why it was added.
Now open the message timeline. A delivered event means the receiving server accepted it and the message is in a spam folder, a filtered tab, or a corporate quarantine — that is a deliverability question, not a sending one. A bounced event names the class and carries the SMTP text, which is a different guide again.
Then follow the timeline. If it shows a bounce, the SMTP response is preserved verbatim on the event and reading a 550 covers how to turn that text into a decision. If it shows a delivery and the recipient still cannot find it, why email goes to spam is the right next stop.
What just happened
You have sent one message through your own instance and you have its id, which is the handle for every event that message will ever produce. The thing most likely to bite you later is the shape of the success response: a 2xx means the message was accepted and spooled, not that anybody received it. Delivery, bounce and complaint all arrive afterwards, on the event stream, and treating acceptance as delivery is how a broken sending domain goes unnoticed for a week.