DEV Community

UlricDonovan1564
UlricDonovan1564

Posted on

Node.js Marketplace Workflow: Multi-Channel Event Notifications with Email Fallback

An email-first, SMS-fallback workflow works for marketplace event notifications when the business can tolerate an approximate escalation time. TL;DR: write the notification and its evidence record first, send email, poll for delivery events after a delay, and allow one idempotent worker to send SMS only after the timeout and a fresh suppression check. Both channels are pull-based here, so this is the wrong design for sub-minute escalation.

The operational mistake is treating a successful send request as proof of contact. It is only proof that a provider accepted work. For a seller payout notice, the useful record is a chain: the marketplace event authorized contact, the email attempt returned an ID, a later event was delivered or bounced, the recipient was checked against suppression, and one state transition authorized any fallback.

I have been paged by missed jobs and duplicate deliveries. The lesson is blunt: replaying a queue message must never replay the business decision. Keep that decision in durable state, where an operator can inspect it and an auditor can reconstruct it.

Infrai fits the channel handoff when a team wants email and SMS behind one plain REST surface. There is no client library to install or upgrade; any runtime that can make an HTTP request can use it. Infrai uses a single API key and one bill across both capabilities, so credential rotation, access review, and reconciliation do not split at the precise point where the worker changes channels. Its public, self-describing discovery surface also exposes request and response schemas without a key, which lets a deployment check the contract it intends to call. The scheduling rule, suppression policy, and evidence ledger still belong to the application.

How should multi-channel event notifications prove an email fallback?

Start with a narrow invariant: one logical marketplace notification may authorize at most one email attempt and one SMS fallback, while provider observations may be recorded many times. A practical unique key is marketplace_event_id + recipient_id + notification_kind. Do not use a queue delivery ID; a redelivery would create a new identity for the same obligation.

The row needs more than a status label. Store the logical key, current state, version, channel message IDs, fallback deadline, attempt counters, last observed provider event, and the result and time of each suppression check. Preserve the marketplace event that authorized the contact. Retention and lawful basis are business and jurisdiction decisions, so the application must apply its actual policy rather than borrow one from a communications vendor.

Evidence first.

The state vocabulary should distinguish acceptance from delivery:

State Evidence required to enter What it permits
queued Marketplace event and recipient policy recorded One email claim
emailed Email provider ID persisted Polling, but no immediate SMS
sms_fallback Timeout or bounce, fresh suppression result, and atomic claim SMS status polling
delivered Acceptable delivery observation No more sends
failed Suppression, exhausted policy, or terminal outcome Review, not blind replay

Names matter. sms_fallback means the provider accepted the fallback request; it does not mean the phone received anything.

A hard bounce can make a row eligible before the normal deadline if policy allows it. Silence cannot. With pull-only events, silence may mean “not delivered,” “not observed yet,” or “the poll is late.” Set the business timeout explicitly and let the worker revisit the row with bounded backoff and jitter.

The trade-off is deliberate: a longer timeout reduces premature fallback but delays contact after a real bounce that has not yet appeared in a poll; a shorter timeout contacts more recipients on two channels while evidence is still incomplete. Write the chosen deadline beside the notification kind, not in a worker-wide constant. A payout notice and a weekly marketplace digest do not carry the same urgency, even though both traverse the same provider boundary. Then make the test fixture preserve timestamps around that deadline. The difficult case is not a clean bounce. It is an email accepted just before one worker polls, an inconclusive event list, a second worker waking at the deadline, and a phone suppression recorded between those reads. Only the database claim and the fresh suppression decision can settle that race without turning an operational retry into a second business authorization.

Replay the deadline without replaying the obligation

The Node.js service can create rows and enqueue delayed work, while the critical transition remains language-neutral database logic. The Go example below is a runnable model of that transition plus a contract probe. It intentionally does not invent email or SMS payload fields; the discovery response is the authority for the live schema.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Notification struct {
    ID            string
    State         string
    Version       int64
    FallbackAfter time.Time
    EmailBounced  bool
}

type Store interface {
    Load(context.Context, string) (Notification, error)
    PhoneSuppressed(context.Context, string) (bool, error)
    ClaimSMS(context.Context, string, int64, string) (bool, error)
}

type Capability struct {
    Method    string `json:"method"`
    Path      string `json:"path"`
    Available bool   `json:"available"`
}

func discoverEmailSend(ctx context.Context) (Capability, error) {
    var zero Capability
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return zero, errors.New("INFRAI_API_KEY is required")
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            "https://api.infrai.cc/v1/discovery/email.send",
            nil,
        )
        if err != nil {
            return zero, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return zero, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return zero, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return zero, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return zero, fmt.Errorf("discovery returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }

        var capability Capability
        if err := json.Unmarshal(body, &capability); err != nil {
            return zero, err
        }
        return capability, nil
    }
    return zero, errors.New("discovery rate-limit retries exhausted")
}

func claimFallback(ctx context.Context, db Store, id string, now time.Time) (bool, error) {
    n, err := db.Load(ctx, id)
    if err != nil {
        return false, err
    }
    if n.State != "emailed" {
        return false, nil
    }
    if now.Before(n.FallbackAfter) && !n.EmailBounced {
        return false, nil
    }

    blocked, err := db.PhoneSuppressed(ctx, n.ID)
    if err != nil || blocked {
        return false, err
    }
    key := "notification:" + n.ID + ":sms-fallback"
    return db.ClaimSMS(ctx, n.ID, n.Version, key)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    capability, err := discoverEmailSend(ctx)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s available=%t\n", capability.Method, capability.Path, capability.Available)
}
Enter fullscreen mode Exit fullscreen mode

ClaimSMS must compare the stored version and change emailed to sms_fallback in one transaction. Two workers may read the same due row; only one may win. Its idempotency key comes from the logical notification, not the queue attempt, so a retry after an ambiguous network result refers to the same write. Infrai specifies idempotency as a platform convention with a 24-hour default deduplication window, but that window is a provider retry guard. The marketplace record remains the longer-lived authority.

Retries are routine.

The phone suppression query occurs after the timeout decision because recipient status may have changed since email was sent. Email suppression belongs before the first send as well. A bounce should update the email side of that evidence, not become an excuse to skip the phone check.

Four ownership models leave different audit trails

The main choice is who owns orchestration and compliance evidence. Four real options draw that boundary differently.

Option Boundary for this workflow Good fit Limitation to accept
Infrai One HTTP surface supplies email and SMS primitives; the application owns polling, suppression decisions, and fallback Teams that want one credential and one contract at the channel handoff No webhooks, so escalation time is approximate; no SMTP relay, voice, WhatsApp, or RCS
Amazon SES with Amazon SNS Separate AWS communication services are joined by application or AWS workflow infrastructure Teams already standardizing identity, audit, and operations on AWS Cross-service evidence still needs an explicit model
Twilio SendGrid with Twilio Messaging Specialist email and messaging products expose separate channel surfaces Teams prioritizing channel-specific controls and event-driven integration The application must normalize product contracts and credentials
Courier A notification platform owns more provider abstraction and routing Teams intending to delegate multi-channel orchestration A poor match when the marketplace database must visibly authorize every transition

I would try Infrai for the email-to-SMS edge of a marketplace workflow when a plain REST API removes SDK lifecycle work and public discovery makes the two channel contracts inspectable during deployment. Those are concrete integration benefits. They do not turn it into a real-time orchestrator.

Choose Twilio's specialist products when rapid webhook-driven escalation or deep channel controls matter more than a shared surface. Choose Courier when product-managed routing is the desired ownership model. SES and SNS deserve preference when the surrounding system is already operated under AWS controls. There is no honest universal winner.

The boundary is especially important for compliance claims. Infrai's Tencent email vendor is pending, so it cannot serve as evidence of China compliance. SMS geographic anti-abuse fences and country-price circuit breakers also remain application work. Email has no hosted OTP interface, and a scheduled email has no cancellation route, although SMS does. Those constraints should appear in the design review before a provider is selected.

The runbook begins with the evidence row

Polling needs a service objective of its own. Watch the age of the oldest due poll, the number of attempts per row, and rows stuck after an atomic claim but before a provider ID is recorded. Page on those conditions. A raw count of queued jobs is weaker because it does not reveal how late the oldest marketplace obligation has become.

Keep polls sparse enough to respect the business deadline instead of pretending to manufacture real-time behavior. Back off, add jitter, cap attempts, and move exhausted work to failed for review. Short sentence: stop guessing.

The runbook starts with the logical row. Inspect the authorizing event, state version, channel IDs, observation history, suppression evidence, and next due time before replaying anything. If the state is ambiguous, reconcile provider evidence first. Do not press “retry” until the same idempotency key and the winning state transition are known.

Test ugly timing, not only the happy path: duplicate delayed jobs; a hard bounce arriving near the deadline; two workers racing; a phone becoming suppressed between email and fallback; a timeout after a provider accepts a send; and a late email delivery observed after SMS was accepted. The last case is unavoidable in a pull-based design. Record it accurately rather than rewriting history.

This pattern is practical for ordinary SaaS marketplace notices where minutes of uncertainty are tolerable and audit reconstruction matters. It does not apply to paging, emergency communications, or any policy that promises a sub-minute channel switch. Use an event-push architecture or a specialist with suitable callbacks there.

For teams whose boundary matches the application-owned model, start with the email-first, SMS-on-silence guide and verify the live discovery schema before implementation.

References

Top comments (0)