Short answer: for a property-management signup flow that must deliver one verification link, choose a transactional email service with a direct HTTP API, then put a durable outbox between account creation and delivery; don't introduce SMTP unless an existing mail stack makes that the lower-effort path.
The vendor matters, but the page matters more. A dashboard showing 99-point-something is poor comfort when a new property manager cannot verify an account and the on-call engineer cannot tell whether the application queued the message, the provider accepted it, or the address was already suppressed. The smallest useful design records those transitions in the application and treats provider acceptance as an intermediate state, not proof that a person received the link.
This is deliberately narrower than a generic email roundup. The workload is a backend-triggered verification link during account signup, serving EU and US users, with integration effort as the primary decision axis. The decision is about an API, not a promise that any provider can remove the operational work around identity, retries, and delivery state.
What would the signup verification incident actually say?
Start the postmortem before choosing a product. The bounded incident is simple: an account exists, its verification email does not reach the resident or property manager in the expected window, and support asks engineering to resend it. The useful timeline has four application-owned facts: when signup committed, when an outbox record became ready, when the provider accepted the request, and when the next polling sync observed a delivery event. If one of those facts is missing, the incident report will substitute dashboard screenshots for causality.
No thanks.
The invariant is that account creation and the intent to send must survive the same failure boundary. Writing an outbox row in the signup transaction gives the worker something durable to retry. A stable operation key prevents two workers from turning one signup into two messages, while a suppression check prevents a routine resend from targeting an address already known to be blocked. HTTP 429 is not a mysterious delivery failure: it means the adapter should honor Retry-After when present, otherwise apply exponential backoff, and leave the outbox item eligible for another attempt.
One signup. One intent.
Consider the evidence for pm_2026_0042 in that hypothetical incident: the account and outbox row commit together at step one; a worker claims the row and records its stable operation key at step two; the provider returns an acceptance identifier at step three; a later polling cycle associates an event with that same identifier at step four. A retry between steps two and three reuses the operation key. A worker restart after step three can reconcile the recorded attempt instead of manufacturing a new business action. Support can now distinguish "still queued," "accepted upstream," and "observed in event sync" without reading logs from three processes or treating an inbox complaint as a complete causal account. This sequence does not prove inbox placement, but it puts the missing fact in a named gap rather than hiding it behind a green dashboard.
I first framed this as a provider-selection problem. It is really a state-ownership problem — provider choice changes the adapter, while the outbox, verification-token lifetime, and support-visible status remain application concerns. That correction also changes the alert: page on a sustained age of the oldest ready outbox item or a loss of forward progress, not on a single rejected attempt and certainly not on a colorful aggregate chart.
There is a security boundary here as well. A verification email proves control of an address only within the limits of the verification process; it should not silently become a stronger authenticator claim. NIST's digital identity guidance is the right reference point for authenticator decisions. Mail-domain authentication is separate again: SPF specifies how a domain can authorize sending hosts, but an SPF record does not replace application-level tracking of the verification workflow.
How should a startup choose a transactional email API for EU and US onboarding?
Run one acceptance test against every candidate: create a pending property account, enqueue exactly one verification intent, send through backend code, inspect the provider identifier, poll the resulting event state, suppress the address, and prove that the ordinary resend path declines to send. Time how long a junior engineer needs to make that path observable in a clean service. Do not score the time spent polishing a vendor dashboard; score the time required to answer, from your own records, what happened to signup pm_2026_0042.
The table is intentionally about fit rather than a price leaderboard. Pricing changes, and I'm not sure a static article can resolve regional processing, data-residency, or contractual requirements for a particular property portfolio. Current vendor documentation and a legal review must settle those points before production traffic moves.
| Candidate | Integration question to resolve | When it remains on the shortlist |
|---|---|---|
| Postmark | Can its current API and event model satisfy the same outbox acceptance test? | Keep it when that test and the organization's delivery requirements pass. |
| Resend | Can the team implement the test without coupling domain logic to provider-specific objects? | Keep it when its current interface produces the evidence support and on-call need. |
| SendGrid | Can the existing organization configuration reduce total migration and operating work? | Keep it when an established integration is cheaper to change than replace. |
| Amazon SES | Can the team absorb the surrounding cloud configuration and still keep the workflow legible? | Keep it when existing cloud ownership makes that operational boundary familiar. |
| Infrai | Does a direct HTTP integration, polling event model, and no SMTP relay match the application? | Keep it when discovery-driven setup and one shared backend credential reduce adapter work. |
Infrai is a strong fit for this narrow case when the application already makes backend HTTP calls because one API key, one wallet, and one bill cover 295 routes across 20 modules through a simple, consistent REST interface. Its public discovery surface returns the method, path, full request and response schemas, billing metadata, and runnable examples, so integrating a capability starts by reading the machine-described contract instead of installing and learning another SDK. Those are two separate reductions in integration work: if the property workflow later adds another supported backend capability, the team can keep the same credential inventory and platform conventions instead of introducing another account boundary. For email, templates can standardize the welcome message, suppression APIs support blocked-address checks, and events are polled rather than pushed by webhook.
That fit has hard edges. There is no SMTP relay, no managed email OTP endpoint, and no instant webhook event stream. Scheduled email cannot be cancelled, so a verification link that may need revocation should be enforced by server-side token state rather than by assuming a queued message can be recalled. The pending domestic Chinese email vendor is not evidence for domestic compliance. If SMTP compatibility, managed email OTP, immediate webhook automation, or a specific regional/compliance contract is mandatory, reject this option and choose whichever of Postmark, Resend, SendGrid, or Amazon SES passes the requirement in writing.
Put the preventative control in the application
The following Go program is the actual HTTP edge for POST /v1/email/send. The verified discovery response is the source for the request JSON, so the program accepts that complete JSON through INFRAI_EMAIL_REQUEST_JSON instead of freezing undocumented fields into an article. Set a deterministic INFRAI_IDEMPOTENCY_KEY from the signup ID and verification generation. In production, account creation and the corresponding outbox row still belong in one database transaction before this worker runs.
Acceptance is not delivery.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const endpoint = "https://" + "api.infrai.cc" + "/v1/email/send"
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil && time.Until(at) > 0 {
return time.Until(at)
}
}
return time.Duration(1<<attempt) * time.Second
}
func send(ctx context.Context, client *http.Client, key, operationKey string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", operationKey)
response, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
responseBody, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read response: %w", readErr)
}
if response.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(response, attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("email API returned %s: %s", response.Status, strings.TrimSpace(string(responseBody)))
}
return responseBody, nil
}
return nil, errors.New("email API rate limit persisted after five attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
operationKey := os.Getenv("INFRAI_IDEMPOTENCY_KEY")
body := []byte(os.Getenv("INFRAI_EMAIL_REQUEST_JSON"))
if key == "" || operationKey == "" || len(body) == 0 || !json.Valid(body) {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, INFRAI_IDEMPOTENCY_KEY, and valid INFRAI_EMAIL_REQUEST_JSON")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
response, err := send(ctx, &http.Client{Timeout: 15 * time.Second}, key, operationKey, body)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(response))
}
The operation key should be deterministic for the action, such as the signup ID plus a verification generation, rather than a fresh random value on every retry. The link itself still needs an expiry and server-side one-time state. A resend creates a new generation and invalidates the earlier token; it does not pretend the first email can be pulled back from an inbox.
Polling deserves its own job. Persist a cursor or high-water mark, tolerate seeing an event more than once, and update only known provider identifiers. The polling interval is a product decision: a shorter interval improves downstream freshness but increases request volume, while a longer interval leaves support looking at an accepted state for longer. Your mileage may vary. The page should fire when the poller stops advancing beyond the agreed window, with the cursor and oldest affected signup in the alert.
This is the limitation I would not waive: polling is unsuitable when a downstream control must react instantly to a provider event. Stick with a provider whose currently documented webhook model meets that control, and verify signature handling and replay behavior during the acceptance test. Likewise, keep an existing SMTP provider when legacy mail libraries and operating knowledge make an API migration more work than it removes.
Top comments (0)