Skip to content
Joseph Gitonga
<-All writeups

Noria Send: email and SMS behind one API

2026-09-22 / 4 min / email / sms / ses / queues / infrastructure

A Resend-shaped service over Amazon SES and OnFon Media. One outbox, one event stream, one suppression list, and a careful answer to the hardest question in delivery: what to do when a provider never replies at all.


Same shape, different wire

Email and SMS look like different problems and are not. Both are: accept a message, queue it, hand it to a provider that may or may not answer, find out later whether it arrived, and stop sending to people who have asked you to stop.

Every product had its own half of that, usually the first half. Send owns all of it once. Projects get an API key and send over plain HTTP in any language. Locally it is Mailpit over SMTP and a logging SMS transport; in production it is Amazon SES and OnFon Media. The API is identical either way, which is the only reason anyone trusts the local path.

One outbox, one event stream, one webhook fan-out and one suppression list serve both channels. WhatsApp is next, and nothing about the shape has to change to take it.

A timeout is not a failure

This is the part worth reading.

There are three outcomes when you hand a message to a provider, not two:

  • The provider refused. A real failure. Safe to mark failed, safe to retry under policy.
  • The connection was never made. Nothing left your machine. A safe retry.
  • The request left and got no answer. You do not know. The message may be on its way to a customer right now, or it may have died in transit.

Most systems collapse the third case into the second, retry it, and send duplicates. Send makes it a state of its own, unknown, and nothing in that state is sent again on a hunch. It is resolved by evidence: a delivery event, or a human decision.

Getting a password reset twice is a small annoyance. Getting a one-time code twice, and having the first one invalidate the second, is a support ticket and a customer who cannot log in.

Queued, rate-limited, and honest about backpressure

The API writes a row and returns immediately with 202. A worker claims rows with FOR UPDATE SKIP LOCKED, dispatches on the row’s channel, and retries with exponential backoff and jitter.

Sends are held to a configured rate across every worker, not per worker. This is the difference between respecting the provider’s limit and discovering it. A provider that throttles you for exceeding a limit you could have read in their docs is a self-inflicted incident, and the fix is not a bigger retry budget.

Idempotency that covers batches, not just messages

Replay an Idempotency-Key and you get the original message back. Reuse the key with a different payload and you get a 409, because that is a bug in the caller and hiding it helps nobody.

Batches are atomic. Every item is validated before any of it is written, so one bad address in a batch of five hundred leaves nothing queued and nothing charged against quota. Replaying the key replays the whole batch rather than sending it a second time. A partially-sent batch is the worst possible outcome and the easiest one to build by accident.

Suppression is per channel, not per person

Hard bounces and complaints suppress an address for that project automatically, and the send API refuses a suppressed recipient before it reaches a provider.

Crucially, suppressing someone for email leaves the same person reachable by SMS, and the reverse. A bounced email address says something about the address, not about the human. Collapsing the two is how you lose your only remaining channel to a customer who never asked you to stop.

Delivery events, at least once

SES notifications land on one webhook, OnFon delivery receipts on another. Both become message events and fan out to the project’s own signed webhook endpoints with retries.

Delivery of those events is at-least-once, and the docs say so plainly, because both our retries and the provider’s redeliveries happen. Consumers treat the event id as an idempotency key and ignore ids they have already processed. Promising exactly-once here would be a lie that each consumer would then discover on its own.

Status

Send went in during September 2026 and is being adopted product by product. Domains, DKIM, SPF, DMARC and return-path records are registered per project with a background verifier, sender ids are approved out of band because the provider approves them out of band, and templates are stored per project and per channel.

If you are running transactional messaging across more than one product and each one has its own half-finished version of this, send me a brief.


Read next