DEV Community

LinusHolm3764
LinusHolm3764

Posted on

SaaS Receipt Event Notification: How to Compare Email vs SMS Providers Across US Europe

Use email for the SaaS order receipt and reserve SMS for an explicit, time-sensitive event notification path. The deciding constraint when comparing a provider in the US or Europe is not the smallest advertised unit price; it is how much application code, operational glue, and delivery evidence the integration needs before a media subscription can truthfully mark a settled payment as notified.

TL;DR: put one typed notification boundary behind the payment-settled event, record attempts separately from delivery, and benchmark every candidate with the same test corpus. Resend, Postmark, and SendGrid can be evaluated for the email side; Twilio and Plivo can be evaluated for SMS. Do not crown a winner from a pricing page. Run the five through the same acceptance test in the regions, message shapes, and failure conditions your service actually has.

Should a SaaS event notification provider use email or SMS?

A receipt is durable account correspondence. A customer may search for it days later, forward it to finance, or need the full line-item detail. Email fits that job better than a compressed SMS. An SMS can still be useful when the product has a separate urgent event, but using it as the only receipt channel pushes structured order data into a poor container and adds phone-number handling to the core checkout path.

The payment event and the notification event also have different truth conditions. A successful provider API response proves that a request was accepted. It does not prove inbox placement, handset delivery, or human reading. Keep those states distinct. This sounds pedantic until a retry sends two receipts and support has no event history.

My integration-effort test is blunt: can the adapter accept one stable object, return a provider message identifier, and translate later status events without leaking provider-shaped data into the order service?

If not, the cheap-looking option has already acquired a glue-code tax.

Build the smallest honest boundary

Start with a channel-neutral receipt and narrow result types. The order service should not know template IDs, vendor status names, or SDK response objects.

type Receipt = {
  eventId: string;
  orderId: string;
  settledAt: string;
  customer: { email: string; phone?: string };
  currency: string;
  totalMinor: number;
};

type Accepted = {
  attemptId: string;
  channel: "email" | "sms";
  providerMessageId: string;
  acceptedAt: string;
};

interface ReceiptChannel {
  send(receipt: Receipt, attemptId: string): Promise<Accepted>;
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally small. eventId gives the consumer a stable deduplication key. attemptId identifies one outbound try, because one business event can have multiple attempts. Money stays in minor units; floating-point arithmetic has no place in a receipt pipeline.

Next, make the consumer claim an event before calling the adapter. The store operations below must be implemented atomically by the database. A unique constraint on eventId is the useful part, not an in-memory set that disappears during a restart.

type AttemptStore = {
  claim(eventId: string): Promise<{ attemptId: string } | null>;
  accepted(attemptId: string, result: Accepted): Promise<void>;
  failed(attemptId: string, reason: string): Promise<void>;
};

async function handlePaymentSettled(
  receipt: Receipt,
  store: AttemptStore,
  email: ReceiptChannel,
): Promise<void> {
  const claim = await store.claim(receipt.eventId);
  if (!claim) return;

  try {
    const result = await email.send(receipt, claim.attemptId);
    await store.accepted(claim.attemptId, result);
  } catch (error) {
    const reason = error instanceof Error ? error.message : "unknown error";
    await store.failed(claim.attemptId, reason);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Do not catch and forget. Let the queue retry according to a bounded policy, while the unique event claim prevents a second logical notification. Provider-side idempotency can add protection when available, but the application still needs its own record because webhook redelivery and worker restarts are normal distributed-system behavior.

There is a trap here: treating every failure as retryable. Picture the worker sending a receipt, losing the response during a timeout, and immediately switching channels. The first request may already have been accepted. Now the customer can receive an email and a text for one settlement, while the order service thinks the email failed. Preserve the ambiguous attempt, query or reconcile its later status when the provider supports that evidence, and retry only through a bounded policy. A timeout or rate limit may deserve another attempt; an invalid address or rejected phone number usually needs data correction, not a hot loop. Normalize errors into at least temporary, permanent, and unknown, then cap unknown retries and send exhausted attempts to review.

Compare five candidates with one harness

Marketing tables answer the wrong question. Build one adapter per candidate and score the work required to satisfy the same contract. For the supplied shortlist, that means email adapters for Resend, Postmark, and SendGrid, plus SMS adapters for Twilio and Plivo. Their public prices and service details can change, so capture dated quotes during procurement rather than freezing them into application logic or an architecture decision.

Use a matrix that your team can reproduce:

Check Evidence to collect Pass condition
First accepted call Fresh-project commit and stopwatch One typed adapter with no provider data in domain code
Duplicate event Two concurrent deliveries of one eventId One logical receipt is claimed
Ambiguous timeout Request accepted, response interrupted Attempt remains traceable and retry is bounded
Status ingestion Signed sample callbacks and replays Duplicate and out-of-order events do not regress state
Regional path Tests from each deployment region Latency and failure evidence are recorded separately
Operator lookup Search by order, event, and provider message ID Support can reconstruct the attempt chain

Benchmark it.

I don't trust a universal score here, and neither should a buying team. Record setup minutes, adapter lines, required secrets, webhook handlers, and manual console steps. Run at least the happy path, a duplicate event, a forced timeout, a permanent destination failure, and a replayed callback. Five tiny adapters tested against one interface reveal more about integration effort than five SDK quick starts. The result is a local measurement, not a claim that another team will see the same number.

Deliverability needs a separate gate. For email, domain authentication and sender practices are part of the system, not settings to postpone until volume rises. Google's sender guidance documents authentication, alignment, transport, and subscription-message expectations. For SMS, test the actual destination countries and sender types you intend to use, then document consent, opt-out, and retention handling with counsel and carriers as appropriate. A US success does not establish a European result.

Keep callbacks outside the payment path

The checkout service should publish payment.settled after the settlement record is durable. A notification worker claims it and sends the receipt. Provider callbacks enter through another boundary, are authenticated, stored raw with controlled access, and then normalized into internal states such as accepted, delivered, failed, or unknown.

Never let a late callback rewrite a terminal state blindly. Store provider event time, receipt time, and the original payload hash. Apply an explicit transition table. One provider's vocabulary may not map perfectly to another's, and pretending otherwise creates false certainty in dashboards.

Keep sensitive fields out of routine logs. Log identifiers, channel, normalized state, duration, and error class. Email addresses, phone numbers, receipt bodies, and callback payloads belong behind tighter access controls and a retention policy. NIST's digital identity guidance is also a useful warning against treating SMS as a strong authenticator; a payment receipt and an authentication secret are different jobs.

For observability, count claims, accepted requests, temporary failures, permanent failures, callback lag, and attempts exhausted. Alert on ratios over a useful window rather than one failed message. Then give support a lookup that starts from orderId. The perfect metrics dashboard is not helpful if an operator cannot answer a customer's specific question.

What I would change at scale

I would add complexity only after measurements justify it. Higher volume may call for per-channel queues, regional workers, adaptive rate controls, and a secondary adapter for a narrowly defined failure mode. It may also justify a template registry with review and localization workflows.

None of that belongs in the first call.

The trade-off is straightforward: a second provider can reduce dependence on one delivery path, but it doubles credential rotation, callback parsing, policy review, test fixtures, and incident surface. Automatic failover can also duplicate messages when the first provider accepted a request but the client lost the response. Require an explicit trigger, retain the attempt chain, and test the ambiguous case before enabling it.

Choose from the completed evidence sheet. Weight integration minutes, operational steps, failure semantics, regional evidence, security controls, and support lookup against the application's requirements. Keep current pricing as one dated input, not the headline. For a settled-payment receipt, the best fit is the adapter the team can operate and audit without contaminating the payment domain.

References

Top comments (0)