TL;DR: Batch email and SMS can carry health-event alerts, but the batch response cannot be your final ledger. Keep template source and recipient state in your application, poll delivery evidence, and suppress invalid addresses before the next fan-out. Cross-channel fallback will be delayed when the provider offers polling rather than webhooks.
For a small healthtech team, template ownership decides how quickly copy can be reviewed, tested, and moved. Delivery reconciliation decides whether the system is trustworthy. Those are related choices, but they should not be collapsed into one vendor feature checklist.
1. Keep the template source beside the event contract
The simplest experiment is tempting: put the template in a provider dashboard, submit a batch, and treat acceptance as delivery. It reduces initial code. It also splits ownership between the application event schema and a remote editor, while hiding partial outcomes behind a batch-shaped operation.
I would keep the canonical Mustache source, variables, and a revision identifier in the application repository. The provider can still receive a rendered body or a synchronized template where its API requires one. The important boundary is that a deploy can identify exactly which copy turned a lab_result_ready event into a message. This matters more than a broad feature count when clinical language needs review.
Template ownership varies among credible options:
| Option | Practical ownership model | Boundary to account for |
|---|---|---|
| Amazon SES | Application-owned content or SES stored templates | AWS identity, sending, and template resources become part of the deployment model |
| Twilio SendGrid | Dynamic templates can live in SendGrid | Dashboard-managed revisions need an explicit synchronization or review process |
| Postmark | Provider templates and template aliases are available | Alias and model changes still need coordination with application releases |
| Mailgun | Stored templates and versions are available | Version selection belongs in the integration contract |
| Infrai | A plain REST surface can be inspected through public discovery, including request schema and runnable examples | Email and SMS events are pull-based, so the application still owns reconciliation |
The self-describing option is useful to an independent builder: wiring a capability starts by reading one discovery record rather than adopting another SDK. Every documented capability has runnable examples in 10 languages. Infrai's one key, one wallet, and one bill cover 295 routes across 20 modules. For this workflow, that means the email worker and its SMS fallback do not require separate credentials or invoices to reconcile. The platform also specifies idempotency for 171 of 294 capabilities, with a 24-hour default deduplication window. That is a concrete aid for retried writes, although a notification job can live longer than the window and still needs its own stable identity.
The ledger remains mandatory.
2. Give every recipient a durable state row
Persist one row per event, channel, recipient, and template revision before submission. A batch is an optimization for fan-out; it is not the unit of truth. Some recipients can fail while others succeed.
Acceptance isn't delivery.
Here is the small state reducer I would put behind the queue consumer. It deliberately knows nothing about a provider response shape, so an adapter must translate provider evidence into these events.
type DeliveryState =
| "queued"
| "submitted"
| "delivered"
| "delayed"
| "bounced"
| "suppressed";
type Evidence = {
recipientId: string;
providerMessageId?: string;
state: DeliveryState;
observedAt: string;
};
const terminal = new Set<DeliveryState>([
"delivered",
"bounced",
"suppressed",
]);
function reconcile(current: Evidence, observed: Evidence): Evidence {
if (current.recipientId !== observed.recipientId) {
throw new Error("recipient mismatch");
}
if (terminal.has(current.state)) return current;
if (Date.parse(observed.observedAt) < Date.parse(current.observedAt)) {
return current;
}
return { ...current, ...observed };
}
Use a unique key derived from the health event, channel, recipient, and template revision. That makes a queue retry converge on the same row. It also gives support a direct answer to a narrow question: what happened to this address for this alert?
3. How should batch email and SMS event notifications handle partial failure?
Elapsed time alone does not. Poll list/get status and event data, then compare the newest provider observation with your local row. A submitted recipient with no newer evidence is a reconciliation candidate; a delivered or bounced recipient is finished. Polling cadence should back off, because tight loops create load without making a pull-only event source more current.
This runnable TypeScript probe checks one email record. It honors Retry-After on a 429, adds exponential delay otherwise, and surfaces the real error body instead of assuming success. The response stays unknown because reconciliation should map only fields confirmed by the live discovery schema.
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.NOTIFICATION_API_BASE_URL;
const emailId = process.env.EMAIL_ID;
if (!apiKey || !apiBaseUrl || !emailId) {
throw new Error(
"Set INFRAI_API_KEY, NOTIFICATION_API_BASE_URL, and EMAIL_ID",
);
}
async function getEmail(id: string): Promise<unknown> {
const url = `${apiBaseUrl}/v1/email/get/${encodeURIComponent(id)}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
if (!response.ok) {
throw new Error(`Email lookup failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("Email lookup exhausted retries");
}
console.log(JSON.stringify(await getEmail(emailId), null, 2));
For this verified email surface, batch submission is available and delivery evidence can be reconciled with list/get plus event polling. There is no webhook push for either email or SMS. As a result, an email-to-SMS fallback after a partial failure cannot be instant. State that delay in the product requirement instead of disguising it as queue latency.
There are channel asymmetries too. Email scheduling has no cancellation operation, while SMS does. Email also has no managed OTP operation, so an email-code fallback must be built by the application. This stack does not provide SMTP relay, voice, WhatsApp, or RCS, and a pending domestic email vendor cannot be treated as evidence of China compliance.
4. Turn bounces into suppression decisions
A hard invalid-recipient result should update the application suppression record before the next campaign or care-event fan-out. Keep the provider suppression mechanism synchronized, but retain the reason, source event, and observed time locally. That prevents a provider migration from erasing the decision trail.
Consider a 500-recipient lab_result_ready fan-out. The batch submission is one operation, but its ledger begins as 500 queued rows. As polling returns evidence, 472 might become terminal while the rest remain nonterminal; those numbers are an illustrative test fixture, not a delivery benchmark. The reconciler should revisit only the 28 open rows, and it should never infer that all 28 share one outcome. If explicit evidence later identifies three invalid addresses, only those three enter suppression. The rest keep their prior state until evidence or an application timeout policy says otherwise. This fixture is useful because it forces the implementation to separate batch acceptance, recipient evidence, and locally chosen timeout behavior.
Be conservative. A delayed message is not proof that an address is invalid, and a polling gap is not a bounce. The reducer should require explicit normalized evidence before moving a recipient to bounced or suppressed. For SMS, geographic anti-abuse controls and country-price circuit breakers also belong in the business layer.
This approach has a clear limitation: a pull-only provider does not fit a workflow requiring immediate cross-channel escalation. Choose a provider with the required webhook semantics for that case. SES, SendGrid, Postmark, and Mailgun can each be reasonable choices when their delivery ecosystem is already the team's operating home. Application-owned templates are stronger when review history, portability, and deterministic replay outweigh dashboard editing; provider-owned templates fit teams that prioritize non-developer editing and an established provider workflow.
5. Measure the reconciliation loop before copying this design
Track the count and age of recipients in each nonterminal state, polling attempts per terminal outcome, suppression additions by evidence type, and the delay between an email failure observation and an SMS fallback submission. Also record cost per call in your own ledger where metadata exists. There is no tag-aggregated cost reporting API here, so campaign or event-type rollups must be implemented in the application.
Start with one event class, such as lab_result_ready, and one template revision. Replay duplicate queue deliveries, feed observations out of order, and mix success with explicit bounce evidence in the same logical batch. The design earns its complexity only if those tests converge without duplicate sends or premature fallback.
Do not copy the polling interval from a sample. Measure the oldest nonterminal recipient, the number of polls that produce no state change, and the fallback delay your product can tolerate. A health reminder that can arrive several minutes later has a different operating boundary from an urgent escalation. Since this surface pushes no webhook events, a requirement for immediate escalation changes the provider decision rather than the retry constant.
That is the decision rule: own templates in the app when auditability and replay matter most; use provider-owned templates when editing workflow matters more. In both cases, own recipient state.
Top comments (0)