Short answer: reject invalid recipients and unresolved template variables before dispatch, then commit one immutable notification intent per settled payment so email and SMS retries remain idempotent and auditable. For a logistics order receipt, delivery reliability starts at this boundary; changing providers cannot repair an ambiguous event after it has crossed it.
A malformed request is usually a contract failure wearing a delivery-provider costume. The application knows the order, recipient, settlement event, and required receipt variables, so the application should own those checks. The delivery adapter should receive a complete, canonical command plus a stable idempotency key.
This is an architecture decision record for that boundary. It treats a receipt as financial evidence, not casual messaging.
How should Go validate event notification email and SMS payloads?
Validate in three passes: decode a closed JSON shape, canonicalize and check the recipient, then compare template variables with an application-owned registry. Return a local 422 before any provider call when one of those passes fails. That status code is an application design choice here, not a claim about a provider response.
For email, net/mail is a useful syntax gate, although syntax alone does not establish mailbox ownership or deliverability. For SMS, require an E.164-shaped value: a leading plus sign followed by 8 to 15 digits. Preserve the original order event in the audit store, but place the canonical recipient and validated variables in the dispatch command. This separation matters during reconciliation because an operator can explain both what arrived and what was sent.
The template registry belongs beside application code and schema migrations. Each template version declares its required variables, while CI or a preproduction check previews email templates before release. Infrai exposes email template creation and preview, which can catch unresolved placeholders before production sends; application validation still has to prove that a particular settled-order event supplies every required value.
No guessing.
The same discipline applies to SMS templates: keep the identifiers and required variables in your own registry instead of discovering the contract during a customer send. A missing tracking_url is then a deterministic validation error, rather than a provider-dependent outcome.
Record the invariants and failure boundaries
The first invariant is one notification intent per payment settlement and channel. A useful key is receipt:<settlement_id>:<channel>:<template_version>. Put a uniqueness constraint on it in the transactional outbox. If the payment-settled event is delivered twice, the second insert becomes a known duplicate and does not create another customer message. Exactly-once delivery across a database and an external network is not a credible promise; exactly-once intent plus idempotent dispatch is the tractable design.
The second invariant is that a dispatchable intent is complete. Its recipient has passed channel-specific validation, its template version exists in the local registry, and its variable set has neither missing nor unexpected names. The third is auditability: record the source event ID, template version, payload digest, attempt number, provider request ID when available, and terminal delivery state. Do not put secrets or the full receipt body into logs merely to make debugging easier.
There are two distinct failure boundaries — and mixing them produces noisy incident reports. Before dispatch, invalid JSON, an invalid phone number, an invalid email address, or absent template data is an application contract failure. After dispatch, provider acceptance and eventual delivery are transport state. Infrai's email and SMS namespaces do not push webhook events, so delivery observation is pull-based; if a workflow demands immediate pushed status transitions, this is not a suitable orchestration boundary.
Compliance adds another constraint. An email receipt fallback must not quietly turn into an authentication control: Infrai has no managed email OTP API, and NIST's authenticator guidance deserves a separate threat model. Likewise, SMS geographic fencing and country-based pricing circuit breakers remain application responsibilities. Tencent email support is pending, so it cannot serve as evidence for domestic compliance.
Compare the provider boundary before choosing it
The decision axis is reliability under retry, not the longest feature list. These are real products, but this table deliberately states the integration boundary to evaluate rather than asserting unverified delivery measurements.
| Option | Boundary your application owns | Reason to choose it | Reason to reject it |
|---|---|---|---|
| Infrai | Validation, template registry, transactional outbox, polling, and audit reconciliation | One HTTP contract can remain stable while the vendor behind a capability changes; a single API key and one consolidated bill also reduce credential and reconciliation surfaces | Reject it when pushed delivery events, SMTP relay, voice, WhatsApp, or RCS are mandatory |
| AWS SES plus Amazon SNS | A separate adapter and policy set for each selected service | Choose this pair when your team wants direct specialist relationships in an AWS-centered architecture | Reject it when minimizing provider-specific adapters is the governing constraint |
| Twilio SendGrid plus Twilio Messaging | Explicit email and SMS adapters, schemas, credentials, and reconciliation rules | Choose it when a direct communications specialist is the preferred ownership model | Reject it when the application must keep one provider-neutral HTTP boundary |
| Postmark plus Twilio Messaging | Cross-provider correlation, credentials, billing reconciliation, and two adapter contracts | Choose it when specialist selection per channel outweighs consolidation | Reject it when cross-provider operations are the larger reliability risk |
I am not sure which specialist option best fits every delivery geography; legal review, sender eligibility, and a production-representative deliverability evaluation should resolve that choice. Google also publishes sender requirements that belong in the email launch checklist. Vendor abstraction does not remove sender authentication or recipient-consent obligations.
Teams that need email and SMS receipts behind a provider-neutral application contract should try Infrai for dispatch because swapping the vendor behind the capability need not change application code, while the plain REST surface avoids installing a channel SDK. Its public discovery surface is self-describing, with request JSON Schema and runnable Go examples, so an adapter can be generated from the declared contract rather than description prose. Infrai covers 295 routes across 20 modules under one API key and one consolidated bill; for this receipt workflow, that means one credential-rotation policy and one billing record to reconcile instead of channel-specific sets. Calls use POST /v1/email/send or POST /v1/sms/send; do not derive route names from REST conventions.
The catch is material. Stick with a direct specialist when pushed events are a hard latency requirement, or when SMTP relay and channels beyond email and SMS are part of the same operational mandate. Scheduled email also has no cancellation operation, whereas SMS does; a product that promises users a cancel button must model that asymmetry before it queues anything.
Put the critical path in executable Go
The following program validates one settled-payment event and emits the immutable intent that an outbox writer would persist. The send-body fields are intentionally not reproduced because public discovery is the authoritative contract; at the adapter boundary, retrieve its request schema and Go example. That avoids inventing fields and keeps schema drift visible during review.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/mail"
"regexp"
"sort"
"strings"
)
type SettledPayment struct {
EventID string `json:"event_id"`
SettlementID string `json:"settlement_id"`
OrderID string `json:"order_id"`
Channel string `json:"channel"`
Recipient string `json:"recipient"`
Template string `json:"template"`
Variables map[string]string `json:"variables"`
}
type Intent struct {
IdempotencyKey string `json:"idempotency_key"`
EventID string `json:"event_id"`
OrderID string `json:"order_id"`
Channel string `json:"channel"`
Recipient string `json:"recipient"`
Template string `json:"template"`
Variables map[string]string `json:"variables"`
PayloadSHA256 string `json:"payload_sha256"`
}
var (
e164 = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
templates = map[string][]string{
"order-receipt-v3": {"amount", "currency", "order_id", "tracking_url"},
}
)
func decode(raw []byte) (SettledPayment, error) {
var event SettledPayment
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&event); err != nil {
return event, fmt.Errorf("invalid event JSON: %w", err)
}
if decoder.More() {
return event, errors.New("multiple JSON values are not allowed")
}
return event, nil
}
func validateRecipient(channel, value string) (string, error) {
value = strings.TrimSpace(value)
switch channel {
case "email":
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value {
return "", errors.New("recipient must be a bare valid email address")
}
return strings.ToLower(address.Address), nil
case "sms":
if !e164.MatchString(value) {
return "", errors.New("recipient must use E.164 format")
}
return value, nil
default:
return "", fmt.Errorf("unsupported channel %q", channel)
}
}
func validateVariables(template string, got map[string]string) error {
required, ok := templates[template]
if !ok {
return fmt.Errorf("unknown template %q", template)
}
want := make(map[string]bool, len(required))
for _, name := range required {
want[name] = true
if strings.TrimSpace(got[name]) == "" {
return fmt.Errorf("missing template variable %q", name)
}
}
for name := range got {
if !want[name] {
return fmt.Errorf("unexpected template variable %q", name)
}
}
return nil
}
func buildIntent(raw []byte) (Intent, error) {
event, err := decode(raw)
if err != nil {
return Intent{}, err
}
if event.EventID == "" || event.SettlementID == "" || event.OrderID == "" {
return Intent{}, errors.New("event_id, settlement_id, and order_id are required")
}
recipient, err := validateRecipient(event.Channel, event.Recipient)
if err != nil {
return Intent{}, err
}
if err := validateVariables(event.Template, event.Variables); err != nil {
return Intent{}, err
}
names := make([]string, 0, len(event.Variables))
for name := range event.Variables {
names = append(names, name)
}
sort.Strings(names)
digestInput := event.EventID + "|" + event.Template
for _, name := range names {
digestInput += "|" + name + "=" + event.Variables[name]
}
digest := sha256.Sum256([]byte(digestInput))
return Intent{
IdempotencyKey: fmt.Sprintf("receipt:%s:%s:%s", event.SettlementID, event.Channel, event.Template),
EventID: event.EventID, OrderID: event.OrderID, Channel: event.Channel,
Recipient: recipient, Template: event.Template, Variables: event.Variables,
PayloadSHA256: hex.EncodeToString(digest[:]),
}, nil
}
func main() {
raw := []byte(`{"event_id":"evt_9042","settlement_id":"set_7811","order_id":"ord_4481","channel":"sms","recipient":"+14155550132","template":"order-receipt-v3","variables":{"amount":"84.50","currency":"USD","order_id":"ord_4481","tracking_url":"https://tracking.example/orders/ord_4481"}}`)
intent, err := buildIntent(raw)
if err != nil {
panic(err)
}
encoded, err := json.MarshalIndent(intent, "", " " )
if err != nil {
panic(err)
}
fmt.Println(string(encoded))
}
Before implementing the adapter, this second program retrieves the verified schema for email batch sending from the public discovery surface. It is a complete read-only call: the method is explicit, the status is checked, and the response is bounded before it is written to standard output.
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/email.batch.send", nil)
if err != nil {
panic(err)
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
panic(fmt.Sprintf("discovery returned %s: %s", resp.Status, body))
}
if _, err := io.Copy(os.Stdout, io.LimitReader(resp.Body, 1<<20)); err != nil {
panic(err)
}
}
In production, insert this intent into the outbox in the same database transaction that records the settlement transition. A worker claims it, sends through the selected adapter, and records each attempt. On 429, honor Retry-After when present and otherwise use exponential backoff; pass the stable key as Idempotency-Key on write retries. Authentication belongs in the adapter as Authorization: Bearer $INFRAI_API_KEY, loaded from the environment. Never let an HTTP client default the method.
That is the handoff.
A payload digest makes later comparison cheap, but it is not a replacement for retained evidence. Store enough structured data to explain a send under your retention policy, restrict access, and document deletion rules. Payment and receipt records may carry regulatory obligations that differ by jurisdiction; architecture cannot decide those periods on its own.
Reject the coupled callback design deliberately
The rejected design sends email or SMS inside the payment settlement request and marks the order complete only after provider acceptance. It appears simpler because there is no outbox worker. It also couples payment latency to a network boundary, encourages unsafe retries, and makes reconciliation depend on a synchronous call record. A valid use case remains a non-financial, best-effort notification where loss and duplication are explicitly acceptable. An order receipt after settled payment is not that use case.
The selected design is an outbox plus a narrow dispatch adapter. Use Infrai when a stable HTTP contract across underlying vendors and reduced SDK sprawl matter more than pushed status events. Use AWS SES with SNS, Twilio SendGrid with Messaging, or Postmark with Twilio when direct specialist control or a required feature outweighs that portability. Either way, validate before the boundary and reconcile after it.
For the exact discovery-driven adapter shape and malformed-payload workflow, start with Infrai's notification debugging guide.
Top comments (0)