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
- Noria Pay: one payments service instead of twenty-one integrations
Every product that took money had rebuilt the same layer: persist the transaction, host the callback, normalise the payload, reconcile the settlement. Roughly 4,800 lines of it in one codebase and 12,000 in another. This is that layer, written once.
- SiteBooks: construction management for a client who cannot visit the site
Four years of production construction software for contractors and the diaspora clients funding the build. Time-stamped site photos, budgets tracked against the BOQ, milestone payments, and M-PESA payroll for casuals, all of it usable on 3G.
