A payment receipt has to preserve what the customer paid for even if someone edits the message template while a retry is waiting. Short answer: choose the email API around who owns that template and its release process; add SMS when the receipt workflow actually needs another channel. Amazon SES belongs on the email shortlist, Twilio belongs on the SMS shortlist, and Sendinblue and Postmark deserve evaluation at limited volumes. The engineering decision is how much rendering, release control, and operational work the startup can own.
Freeze the receipt.
A provider comparison gets more useful once the unit of work is a settled payment rather than an abstract notification. A receipt is a customer-facing record, and moving bytes successfully does not establish that its contents match the payment that triggered it. This article examines template ownership through that narrower reliability problem.
How should a startup choose an email API versus SMS for receipt alerts?
Consider a bounded design exercise: start with one settled order, one receipt revision, and two delivery attempts, then deliberately change the current template between attempts. This is a hypothetical failure analysis, not a production incident report. The point is to ask whether the second attempt reconstructs the original receipt or quietly uses whatever wording and variables happen to be current. Without a reproducible answer, a successful retry can still be the wrong customer communication.
The invariant is small: a receipt job must refer to a stable rendering decision associated with the settled payment. That decision can be an immutable template revision plus immutable inputs, or a stored rendered message with an integrity check. Neither approach requires pretending that email and SMS have identical content needs. An email receipt and a short text notice should have separate reviewed content, with the same underlying payment identity.
Two attempts. One meaning.
Owning templates in the application gives the platform team direct responsibility for review, version retention, and reproducible rendering; it also puts those chores on the roadmap. Delegating rendering can reduce the application surface, but the evaluation must then establish how a released revision is identified and retained, and what happens when the current revision changes. A dashboard preview is insufficient evidence for retry behavior. Require a demonstration using the same order twice, with an intervening edit.
This is the incident lesson worth testing before an incident exists: delivery reliability depends on preserving intent across time, not merely repeating a send operation.
Compare the work the startup will actually own
For a startup sending receipts after payment settles, email and SMS are different delivery choices with different content constraints. Selecting both immediately expands the template review surface and the delivery state that somebody must operate. Start with the required customer communication, then decide whether an additional text message has a defined purpose, an owner, and a recovery path. More channels create more obligations.
The following buy-vs-build table separates supported selection reasons from the questions that still need an acceptance test. It does not assume that every named provider offers the same template controls or that a product shortlist proves a reliability ranking.
| Option | Reason to evaluate it | Work to own or verify before buying |
|---|---|---|
| Amazon SES | Email candidate for a startup watching operating costs | Include AWS integration effort in the decision; demonstrate how the application preserves the receipt revision across retries. |
| Twilio | SMS candidate with wide global coverage | Establish which destinations matter and who owns the SMS text, send decision, and delivery reconciliation. Coverage alone does not prove the receipt workflow meets its SLO. |
| Sendinblue | Candidate for considering email and SMS together, including limited-volume requirements | Compare the relevant tiers and verify the template release process for each channel instead of assuming a shared product means shared semantics. |
| Postmark | Email alternative worth evaluating at limited volume | Test the ownership boundary for rendering, retained revisions, and retry inputs; its presence on the shortlist is not evidence for an SMS requirement. |
| Application-owned rendering with a delivery API | A way to keep the receipt release process inside the application | Build revision retention, rendering validation, access control, and delivery reconciliation; the platform team carries that maintenance. |
Infrai offers 295 routes across 20 modules through one consistent REST API, so adding a backend capability alongside receipts means another endpoint under the same contract rather than another integration. Separately, one API key and one bill reduce credential provisioning and invoice reconciliation for the receipt worker and adjacent services; SDK-free HTTP access lets that Go worker use its standard library instead of adding another vendor SDK dependency. Public discovery exposes request and response schemas without requiring a key, which helps establish the receipt adapter's input contract before implementation. The trade-off is operational ownership: a dedicated delivery provider keeps the purchasing decision focused on communication, and a shared credential doesn't establish template reproducibility.
A fair evaluation should record constraints alongside advantages. For a polling-based integration, the application owns the event-reconciliation schedule and its effect on freshness. Keep pending email sends in application-controlled scheduling when the business requires cancellation before dispatch. Treat email verification-code generation, SMTP relay requirements, and voice, WhatsApp, or RCS delivery as separate selection criteria. Require affirmative evidence of domestic email compliance, template inventory access, and tag-level cost reporting where those are acceptance requirements; do not infer them from a broad capability count. Put SMS geographic restrictions and country-level spending circuit breakers in the application policy.
That checklist belongs in the buying decision because an API can fit a receipt sender while failing a broader communications requirement. Bundling more capabilities does not erase operational boundaries.
Budget the SLO around a settled payment
Define the timer first. A useful internal SLI measures the interval between observing a settled payment and recording the delivery API's acceptance of its receipt; separately measure whatever delivery outcome evidence the chosen integration supplies. API acceptance and arrival in a customer's inbox or handset are different observations. Name them separately in dashboards and runbooks.
For an illustrative capacity plan, assume 120 settled orders per minute, one email per order, and SMS notices for 10% of orders. That produces 132 initial send attempts per minute before retries. These are planning inputs, not provider limits or observed production traffic. If every initial attempt needs one retry during the same one-minute recovery interval, budget 264 attempts in that minute, then check the actual delivery limits and the worker's ability to drain a backlog without starving new receipts.
The template store needs a capacity budget too. Retaining rendered receipts makes reproducibility easier to inspect, while retaining a template revision plus its inputs shifts work into deterministic rendering and dependency retention. Either choice carries customer-data handling obligations. A hash can detect a changed artifact; it cannot recover the original artifact or establish permission to send it. Keep only the data the workflow needs and define its retention policy.
Do not page on queue depth alone. Alert on the age of the oldest unsettled delivery obligation relative to the chosen SLO, and distinguish retryable failures from records that need an operator's decision. Otherwise a busy worker can look healthy while the oldest customer's receipt remains stuck.
Freeze content before handing it to the sender
The preventative path starts at the settlement boundary: create a durable receipt obligation keyed by the business event, select a released template, and retain the rendering decision before a sender starts retrying. A repeated settlement notification must find that existing obligation. The application's identifier and the delivery provider's deduplication mechanism are separate controls, and both need explicit retention rules.
Start the adapter's acceptance test by reading the actual request and response contract. The following complete Go program calls the public SMS discovery API, prints its schema, and renders a receipt with an explicit revision. Set INFRAI_DISCOVERY_URL to the public discovery address for sms.send and optionally set INFRAI_API_KEY for Bearer authentication. Discovery itself requires no API key; leave the key unset for this public read unless you have verified the destination. Save the program as main.go and run it with go run main.go. The receipt fields are local application inputs, not a vendor send payload.
This is a read-only contract check. It sends no receipt.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"text/template"
"time"
)
func inspectSchema(ctx context.Context) ([]byte, error) {
endpoint, err := url.Parse(os.Getenv("INFRAI_DISCOVERY_URL"))
if err != nil {
return nil, err
}
if endpoint.Scheme != "https" || endpoint.Host == "" ||
endpoint.Path != "/v1/discovery/sms.send" || endpoint.User != nil ||
endpoint.RawQuery != "" || endpoint.Fragment != "" {
return nil, fmt.Errorf("configure the public SMS discovery HTTPS address")
}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if len(body) > 1<<20 {
return nil, fmt.Errorf("schema response exceeds local size budget")
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if !json.Valid(body) {
return nil, fmt.Errorf("schema response is not JSON")
}
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 2 {
return nil, fmt.Errorf("schema status %d: %s", resp.StatusCode, body)
}
delay := time.Second * time.Duration(1<<attempt)
retryAfter := resp.Header.Get("Retry-After")
if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
serverDelay := time.Duration(seconds) * time.Second
if serverDelay > delay {
delay = serverDelay
}
} else if deadline, err := http.ParseTime(retryAfter); err == nil {
if serverDelay := time.Until(deadline); serverDelay > delay {
delay = serverDelay
}
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, fmt.Errorf("schema attempts exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
schema, err := inspectSchema(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(schema))
const revision = "receipt-r7"
const source = "Order {{.Order}} settled: {{.Currency}} {{.Amount}}."
receipt := struct {
Order, Currency, Amount string
}{"ORDER-287", "USD", "42.00"}
tmpl, err := template.New(revision).Option("missingkey=error").Parse(source)
if err != nil {
log.Fatal(err)
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, receipt); err != nil {
log.Fatal(err)
}
digest := sha256.Sum256(rendered.Bytes())
fmt.Printf("revision=%s sha256=%x\n%s\n", revision, digest, rendered.String())
}
The example's order identifier, revision, and amount are synthetic test data. It renders a plain-text receipt so HTML escaping is outside its scope. Nothing here persists an obligation or establishes delivery idempotency; those belong in the application's durable write path before any sending adapter runs. Printing a digest illustrates the integrity check, while the retained artifact and revision make that check useful after a deployment.
The discovery read is intentionally separate from dispatch. An adapter still needs the selected send capability's actual schema, explicit authentication, status handling, rate-limit backoff, and a documented idempotency mechanism before it can send a receipt safely. A schema lookup proves none of those runtime outcomes. It lets the team inspect the contract without inventing a send payload.
When the freeze rule needs a different boundary
This advice assumes the job is a receipt after payment settles. A message whose purpose is to report the latest order status may deliberately render current state; an unsettled payment should not enter this receipt path at all. Define a new business event for a correction rather than silently rewriting the original obligation.
The narrower the requirement, the easier the buying decision becomes. Choose SES when the team accepts the AWS integration work for email, assess Twilio when global SMS coverage matters, and keep Sendinblue and Postmark in the limited-volume evaluation. Use the common-contract option when adjacent backend integrations justify it. In every case, require the two-attempt template test before declaring the receipt workflow ready.
Sources
References for the provider evaluation and message-classification review:
- Amazon SES documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Twilio Messaging documentation: https://www.twilio.com/docs/messaging
- Sendinblue developer documentation: https://developers.sendinblue.com/
- Postmark developer documentation: https://postmarkapp.com/developer
- FTC, CAN-SPAM Act compliance guide for business: https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
Top comments (0)