DEV Community

MortimerNilsson7694
MortimerNilsson7694

Posted on

Web-App SMS Alert Suppression: A 6-Step Guide to US/EU Batch Status Polling

Short answer: model SMS delivery as a small, auditable state machine, and make suppression a first-class write before you send a batch. Polling can replace webhooks for a modest support queue, but only if every poll is tied to an idempotency key, a cursor, and a clear stop rule.

A customer-support app has a deceptively simple job: alert an agent, or a customer, when a ticket changes. The messy part starts when a US number bounces, an EU number is malformed, and the same batch is retried after a worker restart. I care about integration effort, so I want one narrow contract and very little configuration.

Keep it boring.

The useful angle is failure containment. A notification service is not the source of truth for a ticket. It is a delivery ledger that reports what it knows, accepts corrections, and prevents the next send from repeating a bad decision. That means the ledger needs enough detail to explain a duplicate, a suppression, or a delayed status to an on-call engineer at 2 a.m., while keeping the send worker small enough that a new teammate can trace one message from support event to terminal state without reading an entire framework.

How should a web app poll US/EU batch SMS status?

Start with three records: notification, attempt, and suppression. notification points to the support event and stores an idempotency key. attempt stores the provider message identifier, destination region, and the latest normalized status. suppression stores a destination hash, a reason, who or what created it, and an expiry policy. Keep phone numbers out of logs; hash them with a keyed digest so an operator can correlate events without exposing the address.

The send path is deliberately boring. Validate the number in E.164 form, check suppression, create the notification row, then enqueue the attempt. A retry must find the existing idempotency key instead of creating another message. If validation fails, record invalid_recipient and create a suppression entry. That is a business outcome, not a transport exception. Done.

Here is the smallest TypeScript control loop I would ship behind an adapter. The adapter can speak to any service with a batch endpoint and a status endpoint; the application never needs to know the vendor's field names.

type DeliveryState = 'queued' | 'accepted' | 'delivered' | 'failed' | 'suppressed';

type Attempt = {
  idempotencyKey: string;
  providerId?: string;
  state: DeliveryState;
  nextPollAt?: number;
};

interface SmsGateway {
  sendBatch(input: {
    idempotencyKey: string;
    messages: Array<{ to: string; body: string }>;
  }): Promise<Array<{ to: string; providerId: string }>>;
  getStatus(providerIds: string[]): Promise<Array<{
    providerId: string;
    state: 'accepted' | 'delivered' | 'failed';
    permanent: boolean;
  }>>;
}

async function pollBatch(
  gateway: SmsGateway,
  attempts: Attempt[],
): Promise<Attempt[]> {
  const ids = attempts
    .map((attempt) => attempt.providerId)
    .filter((id): id is string => Boolean(id));

  if (ids.length === 0) return attempts;

  const updates = await gateway.getStatus(ids);
  const byId = new Map(updates.map((update) => [update.providerId, update]));

  return attempts.map((attempt) => {
    if (!attempt.providerId) return attempt;
    const update = byId.get(attempt.providerId);
    if (!update) return attempt;

    return {
      ...attempt,
      state: update.state,
      nextPollAt: update.permanent ? undefined : Date.now() + 30_000,
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

The 30-second interval is an example policy, not a promise about any carrier. In production, persist nextPollAt and use exponential backoff with a maximum age. Your mileage may vary by country, sender type, and traffic shape.

The suppression rule is where most systems lie

A failed delivery does not always mean “never send again.” Separate permanent recipient failures from temporary carrier or congestion states. A permanent invalid-number result should suppress immediately. A temporary failure should remain eligible until its retry budget expires. Opt-out requests deserve their own reason and retention policy; they are compliance records, not just another error label.

A common first attempt treats one failed status as enough for a dashboard. It is not. An agent can retry a ticket while the batch worker is still polling, and the customer can receive two alerts. The fix is not a clever retry library. It is an explicit transition: failed plus permanent=true creates suppression, while failed plus permanent=false schedules another poll or retry. That distinction also gives support a defensible explanation when someone asks why one recipient was skipped and another was retried.

Use a monotonic event sequence when the gateway provides one. If it does not, attach the poll timestamp and reject updates older than the last observed terminal state. Never let a late accepted update overwrite delivered or suppressed.

A build log for the no-webhook path

The first pass has four jobs.

  1. The API writes a support event and an idempotency key in one transaction.
  2. A worker reads unsent notifications, checks suppression, and submits a bounded batch.
  3. A poller claims due attempts with a lease, fetches status, and writes transitions.
  4. A reconciler closes attempts that exceed the maximum age and emits an operator-visible reason.

Leases matter. Without them, two pollers can read the same due row and race to write different states. A lease timeout should be shorter than the poll window, and the update should include the previous state in its WHERE clause. That gives you compare-and-set behavior without turning the database into a distributed lock service.

Keep metrics tied to those records: queue age, acceptance rate, terminal failure rate, suppression additions, poll lag, and duplicate-prevention count. A single “SMS success” metric hides the exact failure that support teams need to act on.

What changes at 10,000 daily alerts?

At small volume, one poller and a relational table are enough. At higher volume, shard by nextPollAt, cap provider requests per region, and keep raw responses in short-lived object storage for audit sampling. Do not make the support database wait on carrier latency.

Batch size is an integration trade-off. Larger batches reduce request overhead but make partial failures harder to reason about. I would start with a small fixed size, measure p95 poll latency, and increase it only when the ledger remains easy to inspect.

The same interface can sit over a self-hosted SMPP gateway, a regional aggregator, or a hosted HTTP API. Each option shifts work between operations, deliverability tooling, and integration code. A hosted API may be the fastest first call; a self-hosted route may fit strict data residency requirements. Neither removes the need for suppression records.

Trade-offs you should write down

Choice Helps with Costs or limits
Polling Simple worker model and no inbound endpoint Status is delayed; poll quotas need control
Webhooks Near-real-time transitions Public endpoint, signature checks, replay handling
Large batches Fewer outbound calls Coarser retries and harder partial-failure audits
Small batches Clear per-recipient outcomes More queue and request overhead
Hosted gateway Low initial integration effort Provider policy and regional coverage become dependencies
Self-hosted gateway More control over routing and data You own delivery operations and carrier relationships

The catch is that polling is not suitable when an alert must trigger an action within a few seconds, or when the provider cannot expose a stable status lookup. In those cases, use signed webhooks or a queue-native delivery event stream. Stick with a polling design when support can tolerate bounded delay and the team values a small, inspectable integration.

I am not sure a single global retry policy can stay correct as carrier rules change. Re-run the decision with real terminal-state data every quarter, and keep the adapter replaceable.

References

Further reading

Top comments (1)

Collapse
 
marcusykim profile image
Marcus Kim

Treating invalid_recipient as a business outcome that creates suppression-not as a transport exception-is the right boundary for keeping retries honest. The combination of persisted nextPollAt, compare-and-set updates under a poller lease, and refusing late accepted events after a terminal state gives the ledger real operational value. I'd expose that transition history directly in the support ticket before optimizing batch size; at modest volume, reducing the time an agent spends explaining a skipped or duplicated alert is often worth more than shaving a few provider requests.