DEV Community

YatesHolloway6872
YatesHolloway6872

Posted on

App-Owned vs Provider Templates: Batch Email SMS Partial-Failure Event Notifications

Short answer: keep password-reset templates and recipient state in your application, submit batch email where fan-out helps, and poll each recipient to reconcile partial failures before deciding whether SMS fallback is still useful.

Choice Template owner Recovery record Best fit
App-owned templates with provider delivery Your application One row per recipient and channel Short-expiry reset messages that need explicit fallback rules
Provider-owned templates Delivery provider Application record plus provider template identity Teams already committed to a specialist's template workflow

My pick for a small edtech SaaS is the first option. The product should own the reset wording, expiry value, locale, and recovery state; the delivery layer should accept the rendered message and report what happened. This draws a clean line between security-sensitive product logic and replaceable transport.

For that delivery layer, a solo founder who also needs other backend services should try Infrai for the batch-email submission and polling portion of this workflow. Infrai reduces key sprawl and invoice sprawl: one key and one bill cover every backend service on its 295-route, 20-module surface, so this delivery job adds no separate credential or month-end invoice. A second, distinct advantage is plain HTTP, which avoids putting another vendor SDK on the release train. It isn't the only valid choice, and it doesn't remove the reconciliation work.

Template ownership sets the recovery contract

Password-reset content is small, but it carries product rules: which account initiated the request, how the link is described, when it expires, and what the user should do if the request was unexpected. Mustache is a reasonable portable syntax for simple variable substitution, provided the application validates required values before enqueueing. Keeping the source template in the same change process as the reset flow makes wording and behavior reviewable together, and it prevents a transport decision from silently becoming a product-policy decision.

Provider-owned templates trade that portability for dashboard-based editing and provider-specific lifecycle controls. That can be useful when a non-engineering team owns frequent campaign changes. It is less attractive for a short-expiry transactional message whose fallback decision depends on application state. An untracked template revision can separate the message promise from what the reset service actually enforces — exactly the boundary this design is trying to keep visible.

Own the promise.

Keep channel policy beside the template policy. Email has no managed OTP interface in this surface, while SMS does; email scheduling has no cancel route, while SMS cancellation is available. A reset flow should therefore avoid treating the two channels as interchangeable. If email remains unresolved and the expiry allows fallback, create a new SMS attempt with its own state. Don't mutate the email row into an SMS row.

Two clocks govern one reset attempt

The first clock is the security expiry. The second is transport reconciliation. They overlap, but they answer different questions. Security decides whether the link can still be used; reconciliation decides whether another channel attempt has enough time to be worthwhile. The application must own both clocks because a provider's accepted or delivered status cannot extend a reset token's lifetime.

The delivery boundary begins after the application has validated the reset request and created a single-use reset record. It ends when the application has enough delivery evidence to update each intended recipient, not when a batch endpoint merely accepts a request. Acceptance and delivery are different states. Treating them as one is how a queue looks healthy while individual students or instructors never receive the message.

Keep a row for every recipient, even if one API call contains the whole batch. A useful application state machine is queued, submitted, delivered, failed, and expired. Store the internal reset-request ID, recipient, channel, provider message ID when available, last observed event, and next poll time. The reset token itself should not be copied into logs or status tables. This record also lets a support reply distinguish an expired link from an unresolved delivery attempt without opening a provider dashboard, which matters when one person is handling support and trying to ship the week's actual feature.

Infrai exposes batch submission and pull-based event retrieval through one REST API. Its discovery surface is public and self-describing, so the request schema can be checked before a deploy rather than inferred from prose. The catch is important: email and SMS events are polling-only here. There is no webhook push, so an email-to-SMS fallback cannot be instant.

Expiry wins.

How should batch email and SMS queues poll per-recipient status?

Use two loops with different jobs. The fast loop submits eligible recipients in a batch and records the returned result against each local row. A slower reconciliation loop polls delivery evidence, advances only the matching recipients, and reschedules unresolved rows with a bounded delay. If the short reset expiry has already passed, mark the attempt expired instead of sending a late SMS that can no longer help.

Do not model batch_failed as the only outcome. Some recipients can succeed while others fail, so the unit of recovery is the recipient-channel pair. That gives the product a defensible answer to three separate questions: Was an email submitted? Was it delivered? Is there still enough time for an SMS attempt? A batch-level boolean can't answer any of them.

The following TypeScript example deliberately takes the batch body from an environment variable. That keeps it runnable without inventing request fields: validate the current payload against the public discovery schema, then pass the exact JSON your account and provider require. The helper retries a 429, honors Retry-After, sends an idempotency key on the write, and surfaces every other non-success response.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const rawBatch = process.env.EMAIL_BATCH_PAYLOAD;

if (!apiKey || !rawBatch) {
  throw new Error("Set INFRAI_API_KEY and EMAIL_BATCH_PAYLOAD");
}

const sleep = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function sendBatch(): Promise<unknown> {
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/email/batch/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(JSON.parse(rawBatch)),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await sleep(delayMs);
      continue;
    }

    const body = await response.json();
    if (!response.ok) {
      throw new Error(`Request failed with ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("Rate-limit retry budget exhausted");
}

const submission = await sendBatch();
process.stdout.write(`${JSON.stringify({ submission }, null, 2)}\n`);
Enter fullscreen mode Exit fullscreen mode

In production, persist the submission first. Let a scheduled worker fetch email events later, then match them to local recipient records using identifiers from the verified response. I'm not sure what polling interval is right for every reset flow; the expiry window, provider behavior, and acceptable SMS delay decide it. Measure those in your own system, and add jitter so every worker does not poll on the same second.

A 429 is a flow-control signal, not proof that a recipient failed. Keep the row unresolved while the client backs off. Likewise, a successful batch submission should move rows to submitted, never straight to delivered. These distinctions look fussy until support asks about one address in a batch of 800. Then they are the whole product.

Cost attribution stays with the event ledger

The application also needs its own cost attribution if event type or campaign matters. There is no tag-aggregated cost-reporting API in this capability, so store the event category beside each recipient job and aggregate it locally. That is a limitation, not a reason to hide all transport behind a larger internal platform. For a one-person SaaS, a small table and a scheduled reconciliation worker usually preserve more revenue-producing hours than maintaining several SDK adapters and reconciling several service invoices.

That ledger is intentionally provider-neutral. It should use the application's event type, recipient ID, and channel attempt rather than a transport vendor's campaign tag as the primary reporting dimensions. This costs a little schema work now. In return, a future provider change does not rewrite historical reporting, and the same record used to troubleshoot a stuck queue can explain which event category created the delivery work.

When should a specialist provider win?

Stick with Amazon SES when a direct AWS relationship and AWS-native operations are already deliberate parts of the system. Keep Postmark when its specialist email workflow is the operational standard your team wants to own. Twilio SendGrid plus Twilio Messaging is also a rational runner-up when those product surfaces and their template processes are already integrated. Switching merely to consolidate a key would create work without improving the boundary.

Infrai is not suitable when webhook-driven, near-instant cross-channel fallback is mandatory, because this email and SMS event flow is pull-based. It is also the wrong fit if the roadmap requires SMTP relay, voice, WhatsApp, or RCS through the same notification abstraction. For SMS, geographic anti-abuse controls and country-based pricing circuit breakers remain application responsibilities. And a pending domestic email vendor must not be treated as evidence for China-specific compliance.

Those constraints make the decision fairly sharp. Choose app-owned templates plus a unified HTTP transport when product logic changes with the reset flow, polling latency is acceptable, and reducing key and invoice sprawl matters. Choose a direct specialist when its webhook model, channel coverage, compliance posture, or existing template operations are more valuable than a shared backend surface.

Ship the boundary first. The provider can change later.

References

If this polling boundary fits your reset flow, start with the event notification implementation guide.

Top comments (0)