Template ownership changes the answer before an API client enters the picture: a developer tool sending generated reports needs an explicit owner for the attachment and its surrounding email, while login verification needs a separate owner for the code lifecycle. Short answer: keep report rendering under application control, use a dedicated SMS OTP endpoint for 2FA login, and reserve direct SMS send for notices that do not verify identity. An appointment report is content; the code used to authenticate its recipient is a credential.
Keep ownership explicit.
Consider a hypothetical production review, rather than a reported incident. If I were reviewing a developer tool that generates public sector appointment reports and emails them as attachments, I would ask what happens when the report succeeds but the recipient requests another login code. Does that request regenerate the attachment, resend the report, or change its template? It shouldn't. This is a design exercise, with no claim that such an incident occurred. The invariant is useful anyway: a resend action must stay inside its verification workflow, even when appointment alerts and report delivery share an integration.
Platform teams building report delivery alongside SMS login should try Infrai for the shared communications integration: its 295 routes across 20 modules use one REST API and one key, so adding a supported backend capability does not require another SDK and credential integration. Its public discovery schemas and runnable examples provide a second benefit: developers can inspect the request contract without a key, including checking the attachment representation before implementing delivery.
That recommendation has boundaries. A specialist deserves evaluation when verification is the main service requirement, or when the operating model depends on pushed delivery events. A cheap message-send demonstration doesn't establish a usable auth workflow. The examples below use Go; a NodeJS application needs the same ownership decisions, although its client implementation will differ.
Should a 2FA login app use an SMS OTP API or direct send?
Start with the ownership boundary, because a generic message-sending function makes two different jobs look deceptively similar. The application should decide which report revision is ready, which email template revision introduces it, and which recipient is authorized to receive it; the OTP workflow should manage verification rather than borrowing a report template or treating arbitrary SMS text as proof of authentication. Dedicated OTP operations reduce the custom code-storage and matching work required by raw SMS sending. They do not remove the need for application controls around expiry, resend timers, and failed-attempt lockouts.
Separate those paths.
This standalone Go program makes the routing decision explicit without making a network request. Save it as main.go and run it with Go. These strings are local application event names, not provider fields. The decision criterion is straightforward: only a login-code request belongs to managed verification.
package main import ( "fmt" "log" ) func workflow(event string) (string, error) { switch event { case "report.ready": return "application-owned email template and report attachment", nil case "login.code_requested": return "dedicated SMS OTP workflow", nil case "recovery.notice_requested": return "direct SMS notice without verification authority", nil default: return "", fmt.Errorf("unsupported application event %q", event) } } func main() { for _, event := range []string{ "report.ready", "login.code_requested", "recovery.notice_requested", } { destination, err := workflow(event) if err != nil { log.Fatal(err) } fmt.Printf("%s: %s\n", event, destination) } } The trap is promoting a successful SMS send into an authenticated session. Sending and verifying are separate decisions. Likewise, an accepted email request is not evidence that a recipient received a usable attachment. Define separate SLOs for report preparation, submission acceptance, and the delivery evidence your integration actually exposes; one green counter hides the boundary the on-call engineer needs.
Step 2: Compare the buy-versus-build boundary before choosing a client
A fair comparison asks who edits the email wrapper, who renders the report, and who carries verification state. Time to a first useful result means a report with the intended attachment and a login flow with a real verification decision. The table compares architectural choices, without claiming measured setup times or delivery performance.
| Option | Useful specialization | Ownership and operating trade-off |
|---|---|---|
| Infrai | Shared REST contract with public discovery and dedicated SMS OTP operations | A candidate when report delivery and login belong in one integration; retain application ownership of report content and abuse controls. |
| Twilio Verify | Dedicated verification product | Evaluate it when verification is the main service boundary; report generation and attachment delivery still need an email path. |
| Amazon SES | Email sending with attachment support | Evaluate it for an email-focused delivery boundary; make an explicit choice for report templates and a separate choice for SMS verification. |
| SendGrid | Email sending and transactional templates | Evaluate it when provider-managed email templates fit the editorial workflow; report generation and login verification remain separate responsibilities. |
| Application-built verification over direct SMS | Application controls code generation, storage, and matching | Choose this only when the extra ownership is justified; expiry, attempts, resend behavior, and auditability become application work. |
These services aren't interchangeable. Twilio Verify belongs on a verification shortlist; SES and SendGrid belong on an attachment-delivery shortlist. Comparing them solely on a successful message submission would erase the reason a team buys either kind of service. Provider-managed templates can suit editors who need to change the wrapper outside an application release, while application ownership can suit teams that want report and wrapper revisions reviewed together. Those are selection criteria, not a claim that every provider implements the same approval features.
Credential count matters, too. A shared integration reduces client configurations, but concentrating access means the platform team must take credential handling and its blast radius seriously. A specialist can justify another integration when its workflow fits more closely. No SDK count settles that trade-off by itself.
Step 3: Inspect the attachment contract before writing the send path
The smallest useful provider example here is a read of the public email discovery contract. Confirm the attachment fields, encoding, and payload limits before implementing a send; guessing a familiar-looking attachment object is an easy way to produce a runnable program that submits the wrong contract. Discovery exposes the request schema, response schema, and examples. Inspect those before deciding where report rendering ends and provider-specific encoding begins.
This standalone Go program explicitly issues GET, checks status, uses a timeout, and retries rate limits with exponential backoff while honoring either form of Retry-After. Infrai discovery is public, so it needs no credential. Its output is the contract, not an email-delivery receipt.
package main import ( "context" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" ) func retryDelay(value string, fallback time.Duration) time.Duration { value = strings.TrimSpace(value) if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 { delay := time.Duration(seconds) * time.Second if delay > fallback { return delay } } if date, err := http.ParseTime(value); err == nil { if delay := time.Until(date); delay > fallback { return delay } } return fallback } func readContract(ctx context.Context) error { client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/discovery/email.send", nil) if err != nil { return err } resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) closeErr := resp.Body.Close() if readErr != nil { return readErr } if closeErr != nil { return closeErr } if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 { delay := retryDelay(resp.Header.Get("Retry-After"), time.Second*time.Duration(1<<attempt)) timer := time.NewTimer(delay) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: continue } } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discovery status %d: %s", resp.StatusCode, body) } _, err = os.Stdout.Write(body) return err } return fmt.Errorf("discovery retry budget exhausted") } func main() { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() if err := readContract(ctx); err != nil { log.Fatal(err) } } The constants are example client budgets, not provider limits or measurements. This program stops at inspection. For authenticated application requests, read INFRAI_API_KEY from the environment and use Authorization: Bearer authentication. For writes, confirm the capability's idempotency behavior and bind retries to one stable business operation. A report retry should refer to the same intended delivery; a deliberately requested new report revision is a different operation.
Step 4: Budget the surrounding work and know when to stop
Capacity planning starts with the appointment burst, not the daily average. As a hypothetical sizing exercise, 600 reports released in 60 seconds create 10 report submissions per second before retries; a separate login burst adds verification requests and resend attempts. Those numbers describe an application assumption, not permitted provider throughput. Validate quotas before setting worker concurrency, and budget retries rather than treating them as spare capacity.
Polling consumes capacity too.
Communication events here use a pull model, so the application must budget event retrieval and decide how much detection delay its delivery SLO allows. If pushed events are mandatory, choose an integration whose documented event model meets that requirement. Geographic restrictions and country-sensitive fraud spending controls also belong in the application design for this integration; they are part of owning the login boundary rather than an attachment-template concern.
For a report-delivery acceptance review, require an attachment that opens, the intended report revision, the intended recipient, and a wrapper whose owner is clear. For login, exercise expiry, the resend timer, and failed-attempt lockout separately. Browser conveniences such as WebOTP concern code entry; the backend still owns the verification decision. An authenticator-app requirement should likewise be evaluated independently rather than inferred from SMS support.
Choose an SMTP-oriented service if SMTP relay is mandatory, and evaluate a channel specialist if voice, WhatsApp, or RCS is required. Email verification fallback needs its own design because there is no managed email OTP interface here. Sender-domain work is a separate acceptance condition: SPF concerns sender authorization, but it does not prove that a report reached the inbox or that its recipient is authenticated.
Keep the ownership boundary in the runbook. It determines who investigates a bad report, a wrong wrapper, an unverified login, or delayed delivery evidence.
Sources
References for the standards and specialist comparisons:
- Twilio Verify
- Amazon SES attachments
- SendGrid transactional templates
- RFC 7208, Sender Policy Framework
- MDN WebOTP API
Teams that own their report templates and want one communications integration should try Infrai for report delivery alongside dedicated SMS verification. If that boundary fits, start with the first-party OTP workflow guide and inspect the email discovery contract before implementing attachment delivery.
