DEV Community

BarnabyVance6852
BarnabyVance6852

Posted on

Node.js SMS Notifications API: 3 Service Boundaries Before Status Polling

Short answer: choose the SMS service that makes three boundaries explicit: acceptance, delivery observation, and verification consumption. For a fintech signup flow spanning the US and EU, a thin internal adapter with idempotent submission and bounded status polling is usually simpler to operate than letting Node.js application code absorb each provider's request and status model. The cheapest-looking send call is irrelevant if integration work leaves account state ambiguous.

Consider a bounded production design exercise. A customer submits a phone number, receives a link, and opens it to continue account verification. I first draw this as one synchronous request because the user-facing action feels immediate. That model is wrong: accepting a message, a carrier handling it, and the customer consuming the link are three separate events. The invariant is narrower and more useful: one signup attempt may create at most one active verification challenge, while message status is evidence about transport rather than proof of identity.

The account state must never advance because an SMS API reports delivery. It advances only when the server validates and consumes the verification challenge.

Should SMS alerts use a transactional notifications API without webhooks?

An accepted submission tells the application to start observing, not to mark the phone number verified. Delivery can remain pending, become terminal, or be unavailable long enough to outlive the challenge. Polling also introduces its own failure modes: duplicate workers, late responses, and a status regression when an older observation overwrites a newer terminal state.

It doesn't prove identity.

Treat the workflow as two related state machines. The message moves from queued to an observed transport outcome. The challenge moves from active to consumed or expired. Link handling owns the second transition, and it must be atomic. The SMS layer should carry an opaque, single-use value rather than account data or an authorization decision. OWASP's recovery guidance supports the same controls for URL tokens: generate them securely, make them long enough, store them safely, tie them to one user, expire them, and invalidate them after use.

There is a quieter cross-border concern too. Do not make SMS the only recovery path without a separate threat review. If an email fallback exists, it is another delivery system with its own sender-authentication work; SPF defines authorization for hosts using a domain in SMTP identities, but SPF alone does not turn fallback delivery into identity proof. The invariant stays the same across channels: transport signals do not consume the challenge.

Put the boundary in code

The application should depend on a small contract. Keeping the example in Go makes the boundary hard to confuse with a vendor SDK: a send operation returns an internal observation key, and status lookup returns a normalized state. Provider-specific identifiers remain behind the adapter.

package verification

import (
    "context"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "errors"
    "time"
)

type DeliveryState string

const (
    Queued    DeliveryState = "queued"
    Delivered DeliveryState = "delivered"
    Failed    DeliveryState = "failed"
)

type Messenger interface {
    SendVerification(ctx context.Context, destination, link, idempotencyKey string) (string, error)
    Status(ctx context.Context, observationKey string) (DeliveryState, error)
}

type Challenge struct {
    TokenHash [32]byte
    ExpiresAt time.Time
}

func NewChallenge(now time.Time) (string, Challenge, error) {
    raw := make([]byte, 32)
    if _, err := rand.Read(raw); err != nil {
        return "", Challenge{}, err
    }
    token := base64.RawURLEncoding.EncodeToString(raw)
    return token, Challenge{
        TokenHash: sha256.Sum256([]byte(token)),
        ExpiresAt: now.Add(15 * time.Minute),
    }, nil
}

func (c Challenge) Accept(token string, now time.Time) error {
    if !now.Before(c.ExpiresAt) {
        return errors.New("challenge expired")
    }
    if sha256.Sum256([]byte(token)) != c.TokenHash {
        return errors.New("invalid challenge")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The database transaction that calls Accept must also mark the challenge consumed and reject a second consumption. The sample deliberately leaves persistence out because transaction semantics belong to the application's datastore, not the messaging adapter. It also does not treat Delivered as a verification result.

Use one stable idempotency key per challenge creation, not per retry. A timeout after submission is ambiguous: the remote system might have accepted the request before the connection failed. Retrying with a new key can produce two links and two transport records. A stable key lets the adapter reconcile that ambiguity according to its selected backend's documented behavior.

Capacity planning before provider selection

Polling is straightforward only after its ceiling is explicit. Suppose the design target is 20 new signup challenges per second, each remains observable for 15 minutes, and clients poll every 5 seconds. The upper-bound working set is 18,000 observations, and naive individual polling would attempt 3,600 status reads per second. These are planning inputs and arithmetic, not measured traffic. They expose the integration question early: can the adapter batch lookups, reduce cadence after the first minute, and stop immediately on a terminal state?

I initially assume the status loop will be cheap because each read is small. The capacity calculation corrects that assumption before it becomes an operating model: read size is only one term, while request rate, concurrent observations, regional partitions, retry amplification, and the number of credentials that workers must rotate all remain. Compare candidates with that full loop in view, not with a screenshot of one successful send request. This is a trade-off, not a feature checklist.

Start with an SLO framed around user outcome, such as the proportion of valid challenges that can be consumed before expiry, then split its indicators. Track submission errors, status age, poll backlog, terminal transport outcomes, challenge expirations, and successful consumption separately. A single delivery-rate chart hides the boundary that matters.

Backoff needs a deadline. Poll quickly while the result can still change the signup experience, then slow down; add jitter so workers do not synchronize, cap attempts at challenge expiry, and keep the last normalized state monotonic. On-call should be able to distinguish a submission outage from a delayed observation path without reading provider-specific payloads.

Capacity is where simplicity becomes measurable. A service with a compact send API but no practical batch-status mechanism can require more worker capacity and operational tuning than one with a larger initial integration surface. Price per message does not answer that question.

Stop there.

Buy, adapt, or operate the transport?

The useful comparison is integration ownership, not a ranked vendor list. Any candidate should be tested against the same failure script and destination mix.

Option Initial integration Ongoing ownership Lock-in boundary Best fit
Managed API behind an adapter Map submission, errors, and statuses once Credentials, quotas, polling, reconciliation Adapter and normalized states A small team that values a bounded on-call surface
Direct carrier or regional integrations Separate contracts and mappings More routing, compliance, and status normalization Internal routing and data model Sustained volume with staff for telecom operations
Self-operated messaging components Infrastructure plus upstream connectivity Full patching, capacity, abuse, and delivery operations Upstream connectivity remains Teams already operating this control plane for a concrete reason

I would require a proof against the awkward cases before signing a contract: duplicate submission after timeout, an unknown status, a terminal state arriving late, credentials rotating during a poll cycle, and one region becoming unavailable. Record how much application code changes. The preferred option is the one that keeps those cases inside the adapter while meeting the outcome SLO and staffing constraint.

This is also where a simple two-provider abstraction can become dishonest. Providers may expose different status vocabularies and guarantees; flatten only what the application genuinely uses. Preserve the raw response in access-controlled diagnostic storage when policy permits, but do not leak its shape into signup logic.

When this design does not apply

Polling is a reasonable boundary when status volume is bounded, the backend supports authoritative lookup, and a few seconds of observation delay does not block the customer. It is a poor fit when the workload calculation exceeds acceptable read capacity or the business requires near-immediate asynchronous updates. In that case, event delivery can replace most polls, but it adds signature verification, replay protection, endpoint availability, and reconciliation work; periodic repair polling may still be necessary.

The limitation is concrete: without a webhook, observation latency and read load move into your workers. Choose event delivery when the business needs faster status changes and the team can own an authenticated public endpoint; choose polling when a bounded delay is acceptable and keeping inbound infrastructure out of scope materially reduces integration effort. Neither transport removes reconciliation.

A verification link is also the wrong primitive when policy or threat modeling requires a different authenticator. Do not stretch a delivery mechanism into an assurance claim. The security review must decide what proves control of the destination and what additional checks a fintech account requires.

The operational rule is short: separate message acceptance, transport observation, and challenge consumption; capacity-plan the observation loop; then select the least integration surface that preserves those boundaries. Three boundaries. One decision.

Sources

Top comments (0)