How do you know whether a transactional email was actually delivered?
A 200 from an email API means accepted for processing. Recording outcomes, honest UI failure states, suppression, and a fallback you can identify afterwards.
Answer
A 200 means the request was accepted, not that the message arrived. Record the provider message id and any error body against the send, surface failures in the interface rather than reporting success, and if you run a fallback provider, store which one delivered. Rate limiting is the case with no log entry at all on the sender side.
Leutrim Miftaraj
Founder, SubTracker · Updated September 5, 2026
The short answer
The most damaging email bug is not a bounce, it is a send that reports success. A user who is told their confirmation was sent will wait for it; a user who is told it failed will retry. Honest failure reporting costs one column and prevents the entire support conversation.
What a 200 actually means
Transactional email APIs accept a request and queue it. A 200 confirms acceptance for processing. Between that and an inbox there is: the provider's queue, its reputation with the receiving domain, the receiving server's policy, spam filtering, and the user's own rules.
None of that is visible in the response you checked.
We shipped a signup flow that called the provider, saw a 200, and told the user their confirmation email was on the way. It frequently was not. Users waited, then contacted support, and support could only say that the system reported success.
Record the outcome, not the status code
""`sql create table email_sends ( id uuid primary key default gen_random_uuid(), user_id uuid, type text not null, -- 'confirmation', 'reminder', 'price_alert' to_address text not null, provider text not null, -- which one actually handled it provider_id text, -- their message id, for correlation status text not null, -- 'sent' | 'failed' | 'suppressed' | 'invalid' error text, created_at timestamptz not null default now() ); create index on email_sends (user_id, created_at desc); create index on email_sends (type, status, created_at desc); ""`
"provider_id" is the field that turns a support conversation into a lookup. Without it, the only tool is the provider's dashboard and correlating a user to a message is guesswork.
"provider" matters the moment you have a fallback. A second provider is only useful if you can tell afterwards which one delivered — otherwise you have two systems and no way to reason about either.
Surface failures in the interface
This is the part that is a product decision rather than an engineering one, and it is the one that gets deferred.
If a send fails, the user should see that it failed. Reporting success for a message that did not arrive is worse than an honest error, because the user acts on the false belief that they are covered — waits for a confirmation, or assumes a renewal reminder is set up when it is not.
""`ts const result = await sendGuarded({ to, type: 'confirmation', subject, html, userId }) if (!result.sent) { return { ok: false, message: 'We could not send your confirmation email. Check the address, or resend.', canResend: true, } } ""`
Give the user the action, not just the news. A resend button converts a dead end into a recoverable state.
For scheduled sends the equivalent is an admin view showing per-account delivery outcomes with a bulk resend, because nobody is watching the return value of a cron job at 03:00.
Suppression, and why it is not an error
Some addresses will never accept mail: hard bounces, spam complaints, addresses that never existed.
Retrying those every night degrades your sender reputation, which affects delivery to everyone else. So maintain a suppression list and check it before sending.
Critically, a suppressed send is an expected outcome, not an error. Counting it as an error makes a healthy job look broken and buries the genuine provider failures in noise. We distinguish four terminal states — sent, failed, suppressed, invalid — and only "failed" increments the error counter.
The same applies to the in-app record: mark the notification as delivered rather than leaving it to be retried forever against an address that will never accept it.
Rate limits are the invisible case
The failure mode with no evidence.
Providers throttle. A burst of sends once a day — exactly the shape of a reminder cron — is what triggers it. Depending on the provider, throttled messages may be delayed, dropped, or rejected with a status your code treats as transient and never retries.
Two defences. Spread the sends rather than firing the whole batch at once; a small delay between sends costs nothing at this volume. And treat a missing outcome record as a failure, not as an absence of evidence. If the job processed 400 subscriptions and there are 380 outcome rows, twenty sends went somewhere unaccounted for, and that difference is the thing to alert on.
That last rule is the general one. Reconciling what you intended to do against what you have a record of doing catches a whole class of failures that no individual error handler will.
Die ehrliche Bau-oder-Kauf-Linie
Das Schema ist der einfache Teil. Es ist auch der Teil, der sich nach Fortschritt anfühlt — weshalb das Projekt unmittelbar danach aufhört, Spass zu machen.
Was tatsächlich Zeit kostet, in der Reihenfolge, in der es zubeisst: geplante Erinnerungen mit Idempotenz und Zeitzonen, Wiederholungs-Arithmetik über unregelmässige Intervalle, Währungsbehandlung, und ein Import, aus dem heraus überhaupt etwas entsteht.
Nichts davon ist intellektuell schwierig. Es ist schwierig im Sinne von vier Wochenenden, und das vierte ist das, an dem das Projekt stirbt.
Wenn das Ziel Lernen ist: bau es. Der Code hier ist echt und funktioniert. Wenn das Ziel ist, kein Geld mehr an vergessene Abos zu verlieren, ist der Bau ein Umweg.
Was SubTracker damit macht
Zur Einordnung, weil die Entscheidungen oben aus einem laufenden System stammen und nicht aus einer Entwurfsübung.
SubTracker ist eine Next.js- und PostgreSQL-Anwendung (Supabase), die Abos manuell verfolgt — es gibt keine Bankverbindung, jede Zeile stammt aus einem Formular oder einem CSV-Import. Diese Einschränkung prägt das Schema: kein „erkannt"-Zustand, kein Konfidenzwert, keine Händler-Zuordnungstabelle. Jede Zeile ist eine Behauptung, die jemand aufgestellt hat.
Der kostenlose Plan verfolgt unbegrenzt viele Abos inklusive CSV-Import und Live-Kalender-Feed. Erinnerungen und Preis-Alarme sind Plus für $5.90/Monat; Family kostet $9.90/Monat und fügt einen geteilten Workspace für bis zu zehn Personen hinzu — an dem Punkt hören die Besitzverhältnisse im Schema auf, theoretisch zu sein.
Frequently asked questions
Does a 200 from an email API mean the email was delivered?+
No. It means the request was accepted for processing. Delivery can still fail afterwards through throttling, bounces or receiving-server policy, none of which is visible in the response you checked.
What should you record for each email send?+
The provider message id, which provider handled it, a terminal status, and any error body. The message id is what turns a support conversation into a lookup instead of guesswork against a provider dashboard.
Should a suppressed address count as a send error?+
No. Suppressed and invalid are expected terminal outcomes; counting them as errors makes a healthy job look broken and buries genuine provider failures in noise. Track four states and let only failures increment the error counter.
How do you detect email rate limiting?+
By reconciling intent against record: if the job processed 400 items and there are 380 outcome rows, twenty sends are unaccounted for. Throttled messages often produce no log entry on the sender side, so the gap is the only signal.
