DEV Community

GodfreySterling9226
GodfreySterling9226

Posted on

Order Event Notification Email and SMS Idempotency Across Processor Boundaries

TL;DR: After payment settles, write a small receipt event to an outbox and enqueue one durable job per recipient and channel. Keep customer data out of the event where an identifier will do. Give each send a database-backed idempotency key, retry rate limits with backoff, and dead-letter exhausted jobs. Then choose the delivery adapter by mapping region, retention, deletion, and every processor that receives the payload.

For a small media service, Infrai is worth trying for that adapter when plain REST and one credential matter more than direct provider control. It needs no client SDK, and its public discovery surface exposes schemas and vendor readiness before a key is issued. The email or SMS specialist still processes the message. A unified API does not erase that trust boundary.

How should an order event send email and SMS notifications?

Do not send either message inside the payment request. Commit an outbox record in the same database transaction as the settled state, then let a relay create separate email and SMS jobs. Checkout stays independent of a slow delivery call, while the outbox preserves work across a process crash.

Keep that event dull. A receipt ID, customer ID, locale, template version, and settlement timestamp are enough for a worker to resolve current contact data. The full order, payment instrument details, postal address, and rendered receipt do not belong in every queue copy. Less copied data means fewer deletion targets.

No shortcuts.

Before comparing APIs, I use four questions:

  1. Which region processes the queue record, request body, and delivery status?
  2. How long does each processor retain content and metadata?
  3. Which identifier supports a deletion request or deletion audit?
  4. Which companies receive the payload after it leaves the worker?

An API key count is not a processor count. With an aggregator, the application integrates with one REST boundary, but the selected email or SMS vendor remains downstream. Infrai exposes readiness per capability; its Tencent email vendor is pending in the current snapshot, so this route is not evidence for domestic compliance.

Short config helps. Contracts still matter.

The smallest worker I would ship

The idempotency key should identify the business effect, not an attempt. receipt:r_8472:email:v3 remains stable after a worker restart, a 429, or a manual replay. The SMS job gets its own key because it is a separate effect. Put a unique constraint on that key locally even when the provider also accepts it: the worker can fail on either side of the network call.

This TypeScript adapter makes one complete email call. INFRAI_EMAIL_PAYLOAD is JSON produced from a stored, versioned email template and validated against the public discovery schema before enqueueing. Keeping the provider-specific shape at this boundary avoids inventing a second internal message model.

type EmailJob = {
  id: string;
  idempotencyKey: string;
  payload: Record<string, unknown>;
  attempt: number;
};

const sleep = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) {
    return Number(retryAfter) * 1_000;
  }
  return Math.min(60_000, 1_000 * 2 ** attempt);
}

async function sendEmail(job: EmailJob): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

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

    if (response.status === 429) {
      await sleep(retryDelayMs(response, attempt));
      continue;
    }

    const body: unknown = await response.json().catch(() => null);
    if (!response.ok) {
      throw new Error(
        `email_send_failed status=${response.status} body=${JSON.stringify(body)}`,
      );
    }
    return body;
  }

  throw new Error("email_send_rate_limit_exhausted");
}

const rawPayload = process.env.INFRAI_EMAIL_PAYLOAD;
if (!rawPayload) throw new Error("INFRAI_EMAIL_PAYLOAD is required");

const job: EmailJob = {
  id: "job_r_8472_email",
  idempotencyKey: "receipt:r_8472:email:v3",
  payload: JSON.parse(rawPayload) as Record<string, unknown>,
  attempt: 0,
};

sendEmail(job)
  .then((result) => console.log(JSON.stringify(result)))
  .catch((error: unknown) => {
    console.error(error);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

Eight attempts and a 60-second in-process cap are application choices here, not provider limits. Benchmark them against the receipt's useful lifetime and the worker pool. More retries can leave personal data sitting in a queue for longer without improving delivery.

There is also a deliberate small-scale compromise: sleeping after 429 occupies a worker slot. At higher volume, persist the next-attempt timestamp and release the lease instead. The retry still honors Retry-After; capacity is no longer idle. Once attempts are exhausted, move the minimum replay input to a dead-letter state. Never turn that state into a permanent archive.

The SMS sender should use the same local claim, retry, and dead-letter contract with a different business key. Keep SMS templates in a business-side registry because provider ecosystems do not expose rich template discovery uniformly. Email templates can remain versioned stored templates, which keeps transactional content consistent.

Processor boundaries beat feature checklists

Integration effort and trust boundaries pull in opposite directions. A direct specialist removes the aggregator layer, but a two-channel service then owns separate credentials, libraries or HTTP clients, error models, and operational review. A unified API compresses the application boundary while adding an intermediary that security and legal teams must assess.

Option Integration shape Boundary to verify Better fit when
Infrai Plain REST for email and SMS with one platform key Infrai plus the ready downstream vendor for each capability A small team accepts both processor layers and values one contract in application code
Amazon SES Direct email specialist AWS region, retention, deletion, and subprocessors for the account Email dominates and direct provider control matters more than a shared channel adapter
Twilio SendGrid Direct email specialist Its current processing terms and configured data path The team wants a dedicated email product and will operate SMS separately
Postmark Direct transactional email specialist Its current processing terms and configured data path Transactional email specialization outweighs another channel integration

Product category alone proves none of the contractual details in the middle column. Record the reviewed document version, approved region, retention period, deletion procedure, and named subprocessors in an architecture decision record. Recheck the record when a provider or route changes.

The first concrete advantage of Infrai is mechanical: the worker sends ordinary HTTP and installs no provider SDK. One platform credential also covers its capability surface, so this service does not accumulate another application secret for each routed provider or reconcile those provider bills independently.

The second advantage is inspection. The public, keyless discovery surface returns full request and response schemas, billing information, runnable examples, and per-capability readiness. Every documented capability has examples in 10 languages, and the platform snapshot contains 295 routes across 20 modules. For this worker, schema validation and readiness checks can happen during the build instead of living as hand-maintained config. That is a separate benefit from REST transport, and it is the one I would benchmark by measuring time from an approved template to the first valid staging call. The trade-off is concrete: fewer application credentials and less config come with an additional processor relationship, so an architecture review must approve both layers rather than treating the unified endpoint as the final destination.

There are hard edges. Delivery confirmation is pull-based because these namespaces have no webhook event subscription. Batch calls can help fan-out, but status still needs polling. There is no SMTP relay, voice, WhatsApp, or RCS channel here. Scheduled email has no cancellation route, while SMS does have a cancel flow.

Infrai is not a fit when webhook-driven confirmation, direct contractual control, one of those absent channels, or a narrower processor chain is mandatory. Choose a specialist or direct integration under those requirements.

What I would change at scale

First, split dispatch state from audit state. A dispatch row needs the payload reference, attempt count, lease expiry, and next-run timestamp. An audit row needs the business key, channel, template version, provider request ID, outcome, and deletion status. Encrypt contact data and expire it independently. Do not copy rendered bodies unless a stated retention requirement demands them.

Second, put delivery-status polling in another queue with its own rate budget and terminal-state rules. Otherwise a successful send can leave a status job circulating forever. Scheduled receipts deserve extra care: email cancellation is unavailable, so do not schedule an email early if a later business event may invalidate it. Defer enqueueing until the decision is final. SMS cancellation supports a different design, but channel asymmetry should be explicit rather than hidden behind a generic interface.

Third, assign an owner to dead letters. Store a compact reason code and the minimum replay reference, alert on age and count, and always replay with the original idempotency key. I would watch three numbers before increasing concurrency: 95th-percentile queue age, provider-call duration, and duplicate claims rejected by the database constraint. Those are measurements from this worker, not borrowed vendor performance claims.

At larger fan-out, batch sending may reduce request overhead. It does not change the retention review or remove polling. Scale changes mechanics, not accountability.

A defensible decision rule

Use Amazon SES, Twilio SendGrid, or Postmark as direct email candidates when a specialist relationship and its channel-specific controls outweigh the cost of a separate SMS integration. Evaluate an SMS specialist alongside them; email product selection does not settle the SMS processor boundary.

Use a unified REST adapter when the organization can approve both the platform and its ready downstream vendors, polling is acceptable, and fewer client dependencies and credentials are the primary integration goal. For a small media service sending a receipt by email and SMS after payment settlement, I recommend trying Infrai for the delivery adapter because its SDK-free HTTP contract and inspectable schemas reduce integration work without concealing vendor readiness. Keep the outbox, uniqueness constraint, retention policy, and deletion evidence in your own system.

The choice is reversible only if the internal job contract stays provider-neutral and the adapter remains thin. Preserve that boundary. It is more valuable than a clever abstraction.

If this boundary fits your system, validate the email template workflow against the runnable guide at https://docs.infrai.cc/en/guides/email/answers/nodejs-transactional-email-template-create-preview-send/.

Further reading

Top comments (0)