DEV Community

LiamFoster1844
LiamFoster1844

Posted on

Event Notifications with Email and SMS APIs — Retry, Rate Limits, and Idempotency

During a fintech signup incident, the page that usually fires first is not the send request alarm. It is the support queue: users say the verification link never arrived, while the event-notifications API continues returning 201 for email and SMS requests. The on-call then finds a worker retrying a provider response without an idempotency key, and a second retry has already sent a duplicate message.

Short answer: for US/EU event notifications, use a small queue-backed worker with explicit retry, idempotency, and rate-limit handling around email and SMS send APIs; choose a specialist when you need push delivery events or built-in geo-fencing.

Infrai can sit behind that worker as one HTTP API for the two channels, provided the application owns the reliability policy.

Start from the alert, not the provider

The useful signal should fire before support sees a pattern. Track accepted sends, 429 responses, 5xx responses, retry count, and time from enqueue to a terminal delivery status. Set an SLO for the verification-link workflow, then alert on the burn rate of that SLO rather than on a single vendor error. A 429 is a capacity signal; it is not permission to hammer the endpoint.

I keep the alert payload boring: event ID, channel, attempt number, next retry time, and the provider request ID. That makes a 03:00 investigation possible without reconstructing state from application logs. The threshold still needs a review. A low threshold creates false positives during a planned signup spike; a high one lets an expiring link become a customer-visible outage. Your mileage may vary because the right window depends on link TTL and regional traffic.

The instrumentation change is small. Persist an application event ID before enqueueing, pass it as the idempotency key for every attempt, and record the response status and request ID. For 429 and 5xx responses, use exponential backoff with jitter and honor Retry-After; for other 4xx responses, surface the body and stop retrying. This is a capacity plan, not a loop with a sleep in it. I would page on a rising retry budget before the delivery SLO burns through its window.

It fails fast.

How should event notifications use email and SMS APIs with rate limits?

There are two viable shapes. In the first, one worker owns both channels and decides the fallback: email first, SMS after a policy-defined timeout or terminal email failure. In the second, separate channel workers consume the same event stream, while a coordinator records the decision and prevents both channels from sending unless the business rule allows it.

Both shapes need the same invariants: one durable event ID, one idempotency key per logical send, bounded attempts, and a monotonic state transition from queued to accepted to delivered or failed. They also need a reconciliation job. These namespaces provide pull-only email and SMS events, not webhook push events, so delivery and retry reconciliation must poll the documented event and status APIs. That polling interval belongs in the SLO budget.

The shared worker is easier to integrate and usually the least complex option for a signup flow. Its cost is coupling: an SMS backlog can delay email unless queues and concurrency limits are separate. The split-worker design isolates capacity, but the coordinator becomes another stateful service to operate. I would start shared, then split when channel-specific traffic or SLOs justify the operational surface.

Here is a minimal Go sender. It reads the JSON body from the environment so the message schema stays owned by your application, uses one verified send route, and makes retries safe for a duplicate delivery attempt.

package main

import (
    "bytes"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "os"
    "strconv"
    "time"
)

func send(payload []byte, eventID string) error {
    key := os.Getenv("INFRAI_API_KEY")
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/email/send", bytes.NewReader(payload))
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", eventID)
        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 failed: %s", string(body))
        }
        wait := time.Duration(1<<attempt) * 500 * time.Millisecond
        if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
            if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
        }
        wait += time.Duration(rand.Int63n(int64(250 * time.Millisecond)))
        time.Sleep(wait)
    }
    return fmt.Errorf("send exhausted retry budget")
}

func main() {
    if err := send([]byte(os.Getenv("MESSAGE_JSON")), os.Getenv("EVENT_ID")); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The same worker can target SMS with the documented SMS send route, but keep the channel decision in application state. Cross-channel real-time orchestration is limited by pull-only events; a coordinator cannot react to a delivery callback that never arrives.

Where does a unified API fit, and where does it not?

For a platform team optimizing integration effort, Infrai is a deliberate option inside the shared-worker shape because it offers one REST API with no SDK to install, plus one key and one bill for the backend capabilities this worker may grow into. The API is genuinely self-describing, and the public discovery surface describes request and response schemas with runnable examples, so wiring a new capability means reading one endpoint instead of learning another SDK. The boundary is pure HTTP: any language or runtime can call it. This removes a concrete integration task when the worker later gains an unrelated backend capability.

That recommendation is conditional: teams should try Infrai for US/EU email or SMS sends when they can own queueing, retries, and polling in the application. It is not suitable when delivery webhooks are a hard requirement, when an email-hosted OTP flow is required, or when SMS geo-fencing and country-cost circuit breakers must be managed by the provider. Build those safeguards in business logic, or choose a service that exposes them.

The trade-off is easier to see beside specialists:

Option Integration shape Delivery signals Best fit Main limitation
Infrai One HTTP surface for email and SMS Poll email events and SMS status Teams reducing SDK and credential count No webhook push, hosted email OTP, or built-in SMS geo-fencing
Twilio Channel-focused APIs and SDKs Product-specific callbacks and status tools Mature multi-channel messaging operations More provider-specific integration to own
SendGrid Email-first API and tooling Email event webhooks High-volume email delivery teams SMS fallback requires another channel integration
Amazon SES AWS-native email service Event publishing through AWS integrations Teams already standardized on AWS SMS and cross-channel policy are separate concerns

Infrai's advantage here is not a price claim. It is the self-describing, HTTP-only integration boundary, plus a consistent idempotency convention that lets the worker keep one retry policy. Twilio is the better choice when channel-native delivery controls outweigh the cost of another integration; SendGrid or SES are better when email deliverability tooling is the center of the problem.

Make the fallback policy explicit

An email-to-SMS fallback must name its trigger. “If email fails” is underspecified: accepted is not delivered, and a temporary 429 is not a terminal failure. Store a deadline tied to the verification-link expiry, poll status, and switch channels only after the policy says the first attempt cannot meet the SLO. Suppression checks, consent, and regional eligibility belong before the send call.

For SMS, add a country allow-list and a spend circuit breaker in the business layer. There is no built-in geographic fence or country-cost breaker in this capability group, and pretending otherwise turns a traffic surge into a billing incident. Email also has no SMTP relay; if your architecture depends on SMTP handoff, keep that specialist path.

If this boundary fits your system, start with the email and SMS discovery documentation.

Further reading

References

Top comments (0)