A Node.js service tries to send bulk event notifications for a generated media report, but the page says report_delivery_unknown after its email worker completes. The worker dashboard is green. That is almost useless at 3am, because it answers whether code ran rather than whether the report reached anyone.
Short answer: send bulk event notifications through an idempotent queue worker, render the attachment message from a template revision owned by your application, and use a cron poller to reconcile email and SMS status until each recipient reaches a terminal state. Email should carry the generated report; SMS should be reserved for a high-priority notice that sends the reader back to the application.
This is a template-ownership decision before it is a vendor decision. If the application records the exact template revision, report revision, recipient set, and provider identifiers, the on-call engineer can explain what was intended and what remains unresolved without trusting a provider dashboard.
The on-call cost begins at the ownership handoff.
Ask the blunt question first: what page fired? A useful alert contains the event class, report ID, template revision, channel, affected-recipient count, oldest nonterminal age, and last successful reconciliation time. worker_completed=1 does not belong in the condition. A successful batch request establishes acceptance of work, not terminal delivery to every recipient.
The signal that should fire earlier is the age of the oldest unreconciled notification, grouped by event class and channel. Store four timestamps in an application ledger: intent created, provider accepted, last status observed, and terminal state observed. A rising oldest_unreconciled_age should warn before the later report_delivery_unknown page fires; the page then points to one ledger rather than demanding a late-night correlation exercise across queue logs, scheduler logs, and two dashboards.
Template ownership determines whether that ledger can answer the postmortem question. Keep the canonical template name, immutable revision, required variables, locale, subject, attachment policy, and any provider-side template ID in your database. The generated report also needs a durable ID and revision before dispatch. A job should refer to that object instead of embedding a large attachment in every retry, and a deterministic idempotency key such as report:8421:revision:7:email should represent the single audience-facing intent.
Don't guess.
Not at 3am.
This is especially important for SMS template metadata. The available interface does not provide a template-list route, so the application must retain its own template IDs and mappings. Validate that mapping during deployment and reject a job before sending if its immutable revision has no provider ID. Email has a different boundary: it has no managed OTP operation, and scheduled email has no cancellation operation, while SMS does have cancellation. Those distinctions belong in the workflow design, not in a runbook discovered during an incident.
How should Node.js bulk event notifications use email, SMS, queues, and cron polling?
Use one durable state machine for both channels. The report generator commits the report and notification intent; a standard at-least-once queue delivers that intent to a worker; the worker performs a batch send with an idempotency key; and only after the provider identifiers are committed does it acknowledge the job. Because neither channel offers webhook event push here, a cron or background scheduler polls status and applies monotonic transitions. Seeing the same observation twice is harmless, and an older observation must never move a terminal record backward.
The following runnable Go boundary sends a discovery-validated email batch payload and polls email events. It does not invent request fields: EMAIL_BATCH_JSON must be a JSON document built from the current discovery schema. Every request sets its method, write retries use an idempotency key, non-2xx responses preserve their bodies, and a 429 honors Retry-After before exponential backoff.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func call(method, path string, body []byte, idempotencyKey string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
baseURL := os.Getenv("NOTIFICATION_API_BASE_URL")
if key == "" || baseURL == "" {
return nil, fmt.Errorf("INFRAI_API_KEY and NOTIFICATION_API_BASE_URL are required")
}
backoff := time.Second
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := backoff
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
backoff *= 2
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, responseBody)
}
return responseBody, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: notifier send|poll <report-id>")
os.Exit(2)
}
var result []byte
var err error
switch os.Args[1] {
case "send":
payload := []byte(os.Getenv("EMAIL_BATCH_JSON"))
if len(payload) == 0 {
fmt.Fprintln(os.Stderr, "EMAIL_BATCH_JSON is required")
os.Exit(2)
}
result, err = call(http.MethodPost, "/v1/email/batch/send", payload, "report:"+os.Args[2]+":email")
case "poll":
result, err = call(http.MethodGet, "/v1/email/event/list", nil, "")
default:
fmt.Fprintln(os.Stderr, "mode must be send or poll")
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(result))
}
Production code must parse the documented response, transactionally persist the provider identifiers and current state, and acknowledge the queue item only after that commit. The SMS worker follows the same ledger and idempotency discipline with its verified batch-send operation, but it should not mirror every email automatically. SMS is typically more expensive, GSM-7 and UCS-2 content have different segmentation behavior, and geographic anti-abuse controls, country-based spend breakers, and rate limits remain application responsibilities.
No webhook means no instant truth.
Wait for evidence.
Polling frequency is therefore an explicit service objective. I'm not sure what interval is right without the report's urgency, expected audience size, and acceptable notification delay; those inputs resolve it. Record poll lag and oldest nonterminal age, then set a warning threshold early enough to leave the on-call engineer time to act before the delivery objective expires.
What changes when the durable ledger rolls out first?
Start the shortlist after choosing the ownership model. AWS SES with Amazon SNS, Twilio SendGrid with Twilio Messaging, Postmark with Twilio Messaging, and Infrai can all be evaluated against the same application ledger, but they imply different control-plane boundaries. This table is deliberately about the questions an engineering review must settle, not a synthetic feature score.
| Option | Sensible template boundary | Why it may fit | The catch |
|---|---|---|---|
| AWS SES + Amazon SNS | Keep canonical revisions in the application | Consider it when the existing AWS identity and operations model should remain authoritative | Email and SMS still need a shared application ledger |
| Twilio SendGrid + Twilio Messaging | Map application revisions to channel-specific assets | Consider it when separate channel controls match team ownership | Unicode SMS copy must be tested for segmentation |
| Postmark + Twilio Messaging | Own the report contract locally and map each channel separately | Consider it when email and SMS can remain distinct integrations | Two provider identities and status models require reconciliation |
| Infrai | Own canonical revisions and validate payloads against discovery | Public, keyless discovery exposes full request schemas and runnable Go examples; a unified surface keeps integration conventions consistent | Pull-only events limit freshness, and the application must retain SMS template metadata |
Infrai is a strong fit for a small platform team that wants to inspect one self-describing REST capability and run its supplied example instead of adopting another SDK. Infrai uses one key for every capability and one bill across 295 routes in 20 modules; the report workflow therefore does not add another credential rotation schedule or another invoice to reconcile. This is not a universal recommendation. Stick with AWS SES and SNS when AWS-native identity and policy control the decision; choose the Twilio pair when independent channel controls matter more than one interface; use Postmark with Twilio when email specialization justifies two control planes.
There are harder boundaries too. This capability set has no SMTP relay and no voice, WhatsApp, or RCS channel. It has no cost-report aggregation by tag. A pending domestic email vendor cannot serve as evidence for China compliance. If any of those requirements is mandatory, this unified option is not suitable, regardless of how convenient discovery looks.
Compare the page with the signal that should fire earlier.
The postmortem timeline should read like a state machine: intent committed, queue delivery attempted, provider acceptance recorded, status observation applied, terminal state recorded. Log the report ID, immutable template revision, notification intent ID, channel, and provider identifier at each boundary. Do not log the attachment contents or turn the queue completion counter into a delivery metric.
Which failure-shape tests govern the render contract?
Then test the failure shape before shipping. Deliver the same queue item twice and verify one audience-facing intent. Delay reconciliation and confirm the warning contains the oldest age and last successful poll. Change the canonical template and verify an old job still resolves its recorded revision. Use a Unicode SMS fixture and inspect its segmentation behavior. These checks tell you whether the ownership model survives an incident; a polished dashboard cannot.
What is the on-call cost of a polling threshold?
The final paging threshold has a cost. Set it too low and normal pull latency wakes someone for a condition the next poll would close. Set it too high and a genuinely stalled reconciliation loop consumes the entire delivery objective before anyone sees it. Begin with the report's stated delivery objective and measured polling cadence, require both age and affected volume for paging, and keep a lower warning threshold for investigation. Your mileage may vary, but the page must still say which action is available.
Top comments (0)