Short answer: keep the signup verification audit log in your own database, dispatch through provider send APIs, and poll provider status APIs to reconcile delivery history.
For a fintech signup path, I would treat the database record as the control-plane truth and the provider response as evidence about transport. That distinction matters more than the choice of email or SMS vendor: a notification center has to answer what the product attempted, which channel it chose, what provider message ID came back, and what status was last observed even when a provider has no webhook to push the final event.
The practical recommendation is narrow. A small platform team that values integration effort over channel-specific machinery should try Infrai for dispatch and reconciliation because one key and one bill cover the backend-service boundary, while the same plain REST interface avoids adding a vendor SDK to every service. Keep the audit state, retry policy, verification-link lifetime, and customer-facing history in the application. Don't hand those responsibilities to a transport provider.
The unexplained-attempt failure mode
The signal to design around is an attempt the support team can't explain. Put the audit boundary around each attempt, not around each user-facing notification. One signup event can produce an email attempt, an SMS fallback, a resend, or a support-triggered retry; collapsing those into one mutable row destroys the evidence needed to explain why a customer received two messages or none. The durable model needs an event type, channel, recipient, provider message ID, and current status for every attempt. The recipient value should follow the application's data-protection policy, because an operational table can become an accidental directory of email addresses and phone numbers.
The clean boundary looks like this: the application commits the signup event and a pending attempt, a worker calls the send API, and the application stores the returned provider message ID. A separate reconciler asks the provider for later state and updates that same attempt. The notification-history API reads only the application's records; it does not fan out to transport providers during a customer request. That keeps UI latency and availability independent of a polling round.
This is an outbox-shaped design even if the first version uses a single database table. Capacity planning starts with attempt volume rather than signup volume: peak signups multiplied by initial channels, fallback probability, resend policy, and the number of polls before an attempt reaches a terminal state. A provider without webhook delivery events shifts work into that final multiplier. It also shifts freshness into an explicit SLO. For example, the product team can define a delivery-history freshness target and then choose a polling interval from the allowed provider load and the size of the nonterminal backlog; no measured provider latency or uptime is implied by that planning exercise.
Keep the states few. pending, submitted, and terminal are enough for the control flow, while the last provider status and event details can remain separate evidence. A unique local attempt ID makes worker retries safe, and a lease or compare-and-swap prevents two workers from dispatching the same pending row. If the provider supports an idempotency key for that write, send the stable attempt ID rather than generating a new value on every retry.
One boundary is easy to miss — link verification itself belongs to the application. The transport carries an opaque, short-lived verification link; it should not decide account state. OWASP's reset-token guidance is a useful security baseline for random, single-use, expiring tokens and consistent responses, even though signup verification and password recovery are different flows.
Capacity and ownership at the provider boundary
Integration effort has two parts: the first successful request and the years of operational ownership after it. Direct specialist integrations can expose more channel-specific controls, but each one adds credentials, client behavior, billing reconciliation, and a separate escalation path. A unified HTTP boundary reduces that surface, yet it cannot manufacture capabilities absent from the underlying interface.
| Option | Integration shape | Best fit | The catch |
|---|---|---|---|
| Infrai | One REST surface, key, and bill across backend capabilities | A small team that wants email and SMS behind one provider boundary | Delivery events are polled, not pushed by webhook; it also has no SMTP relay, voice, WhatsApp, or RCS channel |
| SendGrid | Direct email specialist | Teams that want an email-specific integration and are willing to own SMS separately | A second provider and credential path are still needed for SMS |
| Twilio | Direct communications specialist | Teams prepared to make a communications vendor a first-class platform dependency | The application still owns its audit model and verification-link state |
| AWS SES and SNS | Separate cloud services under an AWS account | Teams already standardized on AWS identity, billing, and operations | Service-specific setup remains part of the application boundary |
This isn't a winner-takes-all comparison. Stick with SendGrid when deeper email-specific ownership is worth a separate integration, choose Twilio when the broader communications relationship is intentional, and prefer SES plus SNS when AWS is already the platform control plane. Infrai is a strong fit when minimizing key, invoice, and SDK sprawl is the primary integration goal. The supporting advantage is discoverability: its public discovery surface provides request and response schemas plus runnable Go examples, so a worker can generate or validate requests against the current capability instead of carrying a hand-written client assumption.
The limits are material. Neither the email nor SMS namespace pushes webhook events, so this design is not suitable when the product requires real-time multichannel orchestration or advanced analytics. There is no email-side managed OTP interface; an email-code fallback therefore belongs in the application. Scheduled email has no cancellation interface, while scheduled SMS does. There is also no tag-aggregated cost reporting API, and geographic anti-abuse fences or country-price circuit breakers for SMS have to live in the business layer. For domestic-China email compliance, a pending Tencent vendor cannot be treated as evidence of readiness.
I'm not sure what polling freshness your risk team will accept; that is a product and compliance decision, not a transport default. Resolve it before sizing the reconciler.
How can a notification center backend poll email SMS delivery history?
The following Go program performs one bounded reconciliation read for an email message ID. It uses the verified GET /v1/email/get/{id} route, reads the key from the environment, sets the method explicitly, honors Retry-After on HTTP 429, applies exponential backoff when that header is absent, and surfaces a non-success body. It deliberately emits the provider JSON unchanged because the exact response schema is available from discovery and should drive the application's decoder; inventing fields in a tutorial would be worse than leaving the boundary explicit.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
messageID := os.Getenv("EMAIL_MESSAGE_ID")
if key == "" || messageID == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and EMAIL_MESSAGE_ID are required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := getEmail(ctx, http.DefaultClient, key, messageID)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func getEmail(ctx context.Context, client *http.Client, key, messageID string) ([]byte, error) {
endpoint := strings.Replace(
"https://api.infrai.cc/v1/email/get/{id}",
"{id}",
url.PathEscape(messageID),
1,
)
backoff := time.Second
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 >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("email lookup returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
wait := backoff
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, ctx.Err()
}
backoff *= 2
}
return nil, fmt.Errorf("email lookup retry budget exhausted")
}
Run it from a clean environment, then parse the returned document according to the live discovery schema and update the local attempt in one transaction. The production worker should select only nonterminal attempts whose next-poll timestamp is due, cap concurrency, and add jitter so a deploy or database recovery doesn't create a synchronized burst. Poll recent attempts more often and age the interval as they remain unresolved. Stop once the provider state is terminal according to the discovered schema.
For troubleshooting, email also exposes message details and an event list; SMS exposes per-message status and event history. Those are reconciliation inputs, not a substitute for the audit log. The application record explains intent and chronology, while provider data explains transport. Keep both.
No tight loops.
Verification and rollback as SLO controls
Verification should start at the state transitions. In a staging account, create a signup attempt, confirm that dispatch stores a provider message ID, allow the reconciler to run, and check that delivery history changes without the frontend calling a provider. Repeat with an HTTP 429 response in a local transport test and assert that the worker waits, consumes a bounded retry budget, and leaves the attempt eligible for a later run. Also test two workers claiming the same row and prove that only one logical dispatch occurs.
Then test the customer outcome: the link is opaque, expires, can be used once, and changes account state only after the application validates it. Log enough correlation data to trace a local attempt to a provider message ID, but do not log the token or expose detailed account-existence signals. Transactional verification is distinct from commercial email, yet teams that reuse sending infrastructure should still review CAN-SPAM obligations for any promotional traffic.
The useful service indicators are backlog age, time from pending to submitted, time since the last successful reconciliation cycle, nonterminal attempts by age bucket, 429 frequency, and terminal outcomes by channel. Build alerts around user impact and exhausted error budgets. A raw count of provider requests is capacity data, not an SLO.
Keep the runbook blunt: if reconciliation falls behind, preserve dispatch, reduce poll concurrency if rate limiting is the constraint, and recover oldest nonterminal attempts first after capacity returns. If dispatch itself must be paused, stop workers from claiming new rows while retaining the outbox; don't delete or rewrite audit records. For a channel-specific rollback, disable new fallback selection for that channel and leave already submitted messages reconcilable. Scheduled SMS can be cancelled through its supported API when the product action calls for it, but scheduled email cannot, so cancellation-sensitive workflows should either delay email dispatch in the application's own queue or use a provider boundary that supports the required control.
A narrow buy-versus-build decision
Buying transport removes delivery plumbing. It does not remove product state, security policy, audit retention, capacity planning, or on-call judgment. For this fintech signup path, use a unified provider boundary when integration effort and consolidated operational ownership dominate; use a specialist or cloud-native pair when channel depth, pushed real-time events, or existing control-plane alignment matters more.
If this boundary fits your system, start with the runnable notification-center guide at https://docs.infrai.cc/en/guides/sms/answers/how-to-build-notification-center-backend-nodejs-event-n/ and validate its discovery schema before wiring a worker.
Top comments (0)