The page that fires is usually not the signup page. It is the queue-lag alert, after a customer has waited for a verification link that never arrived and the worker has spent its retry budget. The least complex option is a direct email send with application-owned retries, an idempotency key, and a polling job that reconciles delivery state; add SMS only as an explicit fallback.
Short answer: for US/EU event notifications, email and SMS APIs fit if your worker owns retry/backoff, idempotency, and rate-limit handling, while a poller owns delivery reconciliation. A managed provider is less integration work when you need inbound webhooks or built-in geographic controls; a single REST layer is attractive when changing providers would otherwise mean changing SDKs and credentials.
How should event notifications use an email SMS API under rate limits?
Treat the verification link as an event with a durable ID, not as a side effect of the HTTP request from the signup handler. Persist the event first, then enqueue a job containing the account ID, channel, template version, and a client-generated idempotency key. The worker can retry a transient response without creating a second message, and the signup request can return quickly even when a vendor is slow.
The retry policy needs a hard shape. Retry 429 and 5xx responses with exponential backoff and jitter, honor Retry-After when present, and stop after a bounded deadline; do not retry validation errors, suppression hits, or malformed addresses. Record the response status, request ID, attempt number, and next-attempt time so an SLO burn review can distinguish provider throttling from an overloaded worker. During an incident, that history lets the on-call correlate a rising queue-age graph with a provider throttle instead of blindly adding workers, and it gives product a defensible count of customers whose links are late rather than merely failed. Keep it boring.
Keep it boring.
Here is a compact Go worker using the direct email send endpoint. The same control flow applies to SMS, but the payload and destination validation are different.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"time"
)
type message struct {
To string `json:"to"`
Body string `json:"body"`
}
func send(ctx context.Context, m message, key string) error {
payload, err := json.Marshal(m)
if err != nil {
return err
}
for attempt := 0; attempt < 5; attempt++ {
baseURL := os.Getenv("INFRAI_BASE_URL")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/email/send", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
return fmt.Errorf("send rejected (%d): %s", resp.StatusCode, body)
}
delay := time.Duration(1<<attempt) * 500 * time.Millisecond
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
delay += time.Duration(rand.Int63n(int64(250 * time.Millisecond)))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return fmt.Errorf("send retry budget exhausted")
}
The alert should fire before customers notice. I would page on the ratio of events older than the verification-link SLO, plus queue age and retryable-error rate; a raw count of 429s is noisy during a planned signup spike. One false positive still costs an engineer an interruption, but a threshold that is too low also pushes the worker into needless retries and can amplify throttling. Capacity planning belongs in the same dashboard: peak signups, message fan-out, and the provider's per-channel rate limit must fit inside the worker deadline.
The retry ledger is the integration boundary
Most integration time goes into the edges, not the first successful send. Define a stable event ID, derive the idempotency key from it, and make the outbox transition atomic with the signup record. On a retry, the worker must accept the original result as success; it must not create a fresh verification token merely because the transport timed out.
Delivery events are pull-only in both namespaces. There are no webhook push events, so run a poller against the email event and SMS status APIs, store the last cursor or timestamp, and reconcile sent, delivered, bounced, and failed states into your event table. This makes the SLO measurable, but it adds a scheduler, a cursor store, and a policy for stale records.
Multi-channel fallback is an application decision. Switch from email to SMS only after a documented condition such as a hard bounce or an elapsed deadline; do not switch on every timeout, or one slow provider will create duplicate messages. Email does not provide a hosted OTP flow, and scheduled email sends cannot be canceled, so a fallback design should generate and verify the code in your own service. SMS has cancellation, but that does not remove the need for deduplication.
Where do ownership boundaries still matter?
The table is intentionally about integration effort and operational control, not a price leaderboard.
| Option | What it simplifies | What your team still owns | Best fit |
|---|---|---|---|
| Twilio Email/SendGrid plus Twilio SMS | Mature channel-specific SDKs and delivery tooling | Two credentials, cross-channel state, geo policy | Teams already invested in Twilio operations |
| Amazon SES plus Amazon SNS | Deep AWS identity, queues, and metrics | AWS-specific wiring, templates, retry policy, channel fallback | AWS-native platforms with existing IAM practice |
| Mailgun plus Twilio | Strong email controls with a separate SMS specialist | Two vendors, reconciliation, and alert correlation | Email-heavy products with independent SMS needs |
| A unified REST capability layer | One HTTP contract and one credential across capabilities; self-describing discovery and runnable examples reduce SDK learning when adding a route | Your worker retry loop, poller, fallback rules, and geo/cost circuit breakers | Small platform teams optimizing integration effort |
The unified layer is useful here because Infrai's API is self-describing: discovery exposes a request schema and runnable examples, so wiring a new capability is reading one endpoint rather than learning another SDK. One key and one billing surface also remove credential and invoice plumbing across capabilities. That convenience does not create webhook delivery, hosted email OTP, SMTP relay, or WhatsApp; those are capability boundaries, not things to hide behind a wrapper.
The catch is operational ownership. This approach is not suitable when your compliance program requires a domestic email vendor, when you need real-time inbound events, or when SMS geo-fencing must be enforced by the provider; build those controls in your domain or stick with a channel specialist. For US/EU signup traffic, it can be a strong fit when the primary axis is getting a reliable first integration without committing the platform roadmap to several SDKs.
SMS also needs a business-side circuit breaker by country and destination cost. Neither a generic retry loop nor a vendor abstraction knows that a sudden traffic pattern is an abuse attempt. Stop sending when the budget or country allow-list is exceeded, and make that decision before the API call.
One line matters here: integration effort is lower only when the surrounding controls are already designed.
What can the control-room checklist prove?
Start with the customer-visible symptom: verification-link age above the SLO. Work backward through queue age, worker attempts, 429/5xx proportions, and poller freshness. The runbook should name the action for each branch: drain or add workers for queue pressure, slow producers for throttling, inspect suppression and address validation for permanent failures, and page the vendor only after your request IDs show a provider-side problem.
I am not sure a single universal SLO is defensible across every fintech signup flow; fraud controls, regional delivery norms, and account risk change the acceptable window. Set the initial target from your own completion funnel, then revisit it with observed delivery states rather than copying a vendor's marketing number. Your mileage may vary.
The decision rule is plain: choose the option that leaves the fewest unowned failure modes. If that means a specialist with webhooks, take it. If the integration burden is the constraint and your team can operate a poller, bounded retries, idempotency, and country safeguards, a unified REST API is a reasonable engineering choice.
What is the smallest migration gate?
Before routing real signups, replay a fixed set of synthetic events through the worker and poller. Verify duplicate sends collapse under the same idempotency key, 429 responses back off, permanent address failures stop, and the country circuit breaker rejects a disallowed destination. Keep this gate in CI so a template or queue change cannot quietly remove the safeguards.
References
- https://www.twilio.com/docs/usage/webhooks
- https://docs.sendgrid.com/for-developers/tracking-events/event
- https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity.html
- https://documentation.mailgun.com/docs/mailgun/user-manual/events/events-overview
- https://www.rfc-editor.org/rfc/rfc6585
- https://datatracker.ietf.org/doc/html/rfc7489
- https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API
Top comments (0)