Skip to content

Send your first email

From an API key to a delivered message, in curl and in code. Includes what to check when the response is a 200 and nothing arrives.

SendingBeginner8 min readUpdated

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.

TL;DR

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.

POST /v1/emails
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.

TL;DR

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.

POST /v1/emails · 200
$ 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
FieldAlways there?What it is for
idYesThe primary key of this message, minted before a provider is chosen. Store it.
created_atYesThe moment of acceptance, not of delivery. ISO 8601, UTC.
suppressedOnly when non-emptyThe recipients that were dropped. Its presence is itself the signal that this send went to fewer people than you asked for.
provider_message_idNot in this responseRecorded 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.

What the 200 already did
  • 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
What the 200 did not do
  • 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.

TL;DR

Send an Idempotency-Key header and a retry inside 24 hours returns the original response instead of doing the work again.

POST /v1/emails
$ curl -H "Idempotency-Key: order-4821-receipt" … /v1/emails
200 { "id": "email_2Nq8x…", "created_at": "…" }
# connection drops. same key, same body, again:
$ curl -H "Idempotency-Key: order-4821-receipt" … /v1/emails
200 { "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.

AnswerWhat happenedWhat 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_requestsAnother request with the same key is still in flight.Wait, then retry. Hammering makes more of these, not fewer.
400 invalid_idempotent_requestThe 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.

THE SDK ALREADY DOES THIS
The Node SDK attaches a generated Idempotency-Key to every POST unless you pass one, and only retries a POST when a key is present — an automatic retry without one is a duplicate-send generator. Passing idempotencyKey: null explicitly opts out of both, which is a legitimate choice and is treated as one.

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.

TL;DR

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.

01
The sending domain is not actually verified

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.

02
The transport credentials are wrong or missing

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.

03
The recipient is on the suppression list

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.

04
The event stream says it was delivered

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.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.