Short answer: choose a transactional email service with a direct API, a queryable suppression list, and delivery events your team can reconcile after every onboarding send; the easiest integration is the one that keeps a bounced address out of every later retry, not the one with the shortest send snippet.
For a B2B SaaS welcome flow, I would put delivery reliability ahead of nominal price. Signup traffic is retry-heavy by nature: the browser retries, a queue redelivers, and an operator replays work after an incident. I've been paged by missed jobs and duplicate deliveries, and the useful lesson is painfully narrow. A successful request is not proof that the workflow has converged, while a failed recipient must become durable input to the next attempt.
Keep that invariant in the runbook: a known-invalid recipient is never eligible for another onboarding send. Everything else, including vendor choice, follows from it.
How should a startup choose a transactional email API for onboarding?
Start with the failure path. The application accepts a signup, records an immutable onboarding message ID, and enqueues a send intent. A worker checks whether the address is suppressed before it submits the welcome email. Later, a reconciliation job polls delivery events and updates the local recipient state. If a bounce makes the address invalid, the next worker run stops before sending. This design works with delayed information because correctness does not depend on a webhook arriving at exactly the right moment.
The send intent needs its own stable identity. Don't derive it from a queue delivery attempt, because attempt numbers change during replay. A practical key is the application event ID plus the message purpose, such as signup_01J...:welcome-v1; retain that key through timeouts and retries. The provider call also needs an explicit timeout and bounded retry policy. Treat HTTP 429 as backpressure, honor Retry-After, and retry with jitter. Treat an ordinary 4xx response as a reason to stop and surface the body to the worker log rather than hammering the same request.
This is the incident boundary I care about:
- The database owns whether a welcome message is due.
- The provider owns delivery and bounce observations.
- A polling job reconciles those observations into local suppression state.
- Every replay runs the suppression check again.
No cleverness required.
Polling does widen the interval between a bounce and the local update. Size that interval from the harm of one additional attempted message, not from a generic desire for “real time.” A five-minute internal target might be sensible for one product and wrong for another; the available evidence here doesn't establish a universal cadence. Your mileage may vary. What matters is that the poll has a durable cursor, overlap between windows, and idempotent processing, so a worker restart cannot create a blind spot. Those are application requirements, not assumptions about a particular event response.
EU and US operations add a separate gate. Before selection, ask each vendor for current, written evidence about processing regions, data residency, subprocessors, retention, and domain authentication. A marketing region label is not enough to close a compliance review. SPF also deserves explicit ownership: RFC 7208 defines how a domain authorizes sending hosts, but it does not replace bounce processing or recipient suppression.
The bounce is part of the write path
A welcome email pipeline is often drawn as a single arrow from signup to send. Operationally, it is a loop: intent, submission, observation, suppression, and possible replay. If the observation leg is absent, the system can keep trying an address that has already told you it cannot receive mail. If suppression lives only in worker memory, a deployment erases the safety decision.
Store enough local state to answer three questions during an incident: which application event caused this message, what the latest known recipient disposition is, and whether another attempt is allowed. Preserve provider request IDs when they are returned, but do not make a provider ID the only join key; your application creates the intent before a remote service can assign anything. This also makes vendor migration less dramatic because the audit trail still starts with an ID you control.
There is a subtle race worth putting in the runbook. Worker A checks an address and sees no suppression. A bounce from an earlier message is then observed. Worker B processes that event and suppresses the address, while Worker A is still preparing its request. A preflight check reduces bad sends but cannot make that race disappear. The stronger control is a state transition in your own database immediately before enqueueing, combined with reconciliation and a policy that prevents repeated retries after an ambiguous result. I'm not sure any vendor-side check alone can close an application-side race; a transactional outbox or equivalent local state machine is what resolves the ownership problem.
Authentication messages deserve another decision. The email capability discussed here does not provide a managed email OTP interface, so a team that needs email verification codes must build that flow itself, including code lifetime, attempt limits, and abuse controls. NIST SP 800-63B is a better starting point for authenticator policy than copying a welcome-email retry loop. Keep verification and onboarding as separate message purposes even if they eventually use the same sending API.
A preventative Go check before replay
The following program performs the remote half of the guard against Infrai: query suppression before a worker proceeds. The unlinked comparison keeps the service base URL in configuration, while the path is the verified suppression route. The program prints the documented service response rather than inventing a response struct; the production adapter should translate that response into the local eligibility model described above.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
key := os.Getenv("INFRAI_API_KEY")
email := os.Getenv("RECIPIENT_EMAIL")
if baseURL == "" || key == "" || email == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_BASE_URL, INFRAI_API_KEY, and RECIPIENT_EMAIL")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := checkSuppression(ctx, http.DefaultClient, baseURL, key, email)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func checkSuppression(ctx context.Context, client *http.Client, baseURL, key, email string) ([]byte, error) {
const route = "/v1/email/suppression/check/{email}"
endpoint := baseURL + strings.Replace(route, "{email}", url.PathEscape(email), 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("suppression check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("suppression check exhausted retries")
}
func retryDelay(retryAfter string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Second * time.Duration(1<<attempt)
}
Run it as the adapter for a worker guard, then make the application decision explicit: suppressed means stop, record the reason, and acknowledge the queue item; eligible means continue to the separately idempotent send step. In production, commit each polled event ID, suppression update, and polling cursor in one database transaction. Do not interpret an adapter timeout as eligibility. Retain the job for a bounded retry rather than guessing.
The sample's GET has no remote write to deduplicate. The actual send does: retain the same application message ID for every retry and use the provider's documented idempotency mechanism. Also cap attempts. Infinite retries turn a temporary ambiguity into a permanent load generator, and they make a postmortem much harder because the original failure is buried under repeated noise.
Compare operations, not landing pages
The shortlist below is intentionally a decision worksheet, not a feature verdict. AWS SES, Postmark, and Resend are real alternatives, but their current regional, event, suppression, and contract details need to be checked in their own documentation during procurement. The facts available here do not support pretending those details are interchangeable.
| Option | Reason to put it on the shortlist | Evidence required before production |
|---|---|---|
| AWS SES | Your team may prefer to evaluate a service in its existing AWS operating boundary | Confirm API workflow, bounce/event delivery, suppression behavior, and exact EU/US processing requirements |
| Postmark | Your team may prefer to evaluate a product focused on transactional email | Confirm event timing, replay semantics, suppression controls, residency, and retention |
| Resend | Your team may prefer to evaluate a developer-oriented email API | Confirm idempotency behavior, bounce reconciliation, suppression controls, and regional commitments |
| Infrai | A plain REST API needs no SDK or client-library lifecycle, and the same key and conventions can cover other backend capabilities | Accept polling-only events, direct HTTP instead of SMTP, and no cancellation for scheduled email; validate regional and vendor readiness for the deployment |
For a small team already making backend HTTP calls, the last row is a strong fit because the integration surface stays small and suppression checks are available. It isn't the automatic winner. Stick with an SMTP-oriented provider when legacy mail libraries or an SMTP relay are a hard dependency. Choose an option with webhook delivery when downstream automation cannot tolerate a polling delay. If the project needs managed email OTP, voice, WhatsApp, or RCS, this capability does not cover that requirement. Domestic China email delivery also needs separate compliance evidence; a pending domestic vendor is not such evidence.
The catch is that “easy” changes meaning after launch. A compact API is easy on day one. On day 100, easy means an operator can replay a bounded window, prove that duplicate application events did not duplicate sends, inspect why an address became ineligible, and estimate how stale the event poll is. Put those checks in the acceptance test and score every vendor against the same incident, using a test domain and addresses you control.
I would run three drills before committing. First, submit the same application intent twice and verify the chosen idempotency contract. Second, create a controlled invalid-recipient case and measure when the application suppression state converges; record the observed number instead of borrowing a promise from a sales page. Third, interrupt the polling worker between fetching and committing its cursor, then restart it and prove that overlap neither loses nor double-applies events. Those drills reveal more about delivery reliability than a comparison of quick-start line counts.
The decision rule for a welcome-email service
Choose the direct API path when the app already sends from backend workers, the team can operate a delayed reconciliation poll, and suppression is checked on every replay. Reject it when SMTP compatibility, immediate webhook-driven automation, managed email OTP, scheduled-email cancellation, or unsupported communication channels are requirements. That is a capability boundary, not an implementation detail.
Write the operating contract before the vendor contract: one durable intent ID, one owner for recipient eligibility, bounded retries, a replayable event cursor, and an alert on reconciliation age. Then test the shortlist under the same bounce and retry sequence. Cheapest can remain a procurement input, but it should not overrule a failure path the on-call engineer cannot explain.
Delivery systems remember mistakes.
Top comments (0)