DEV Community

YukiKobayashi880
YukiKobayashi880

Posted on

Node.js Event Notification Payloads — JSON Schema for Email and SMS Templates

Short answer: validate the event against a versioned JSON Schema before choosing a channel, then validate the rendered recipient and template again immediately before dispatch. That boundary makes a malformed payload a local, searchable failure instead of a mysterious missing verification link.

For a developer-tool signup flow, the invariant is simple: a user gets one usable link, the link is bound to the right account, and every rejected attempt leaves enough evidence to replay safely. I would keep the event payload provider-neutral, make channel adapters boring, and treat delivery status as a separate fact from payload validity. A message accepted by an SMTP or SMS gateway is not proof that a person received it.

The cost of accepting bad data is paid in retries, duplicate links, and support tickets.

The event is an envelope, not an HTML fragment. It carries an event id, schema version, account id, verification URL, expiration timestamp, and a set of explicitly named template variables. Email and SMS renderers may have different length and escaping rules, but they should consume the same trusted values.

There are three useful failure boundaries:

Boundary Rejects Evidence to retain
Ingress missing fields, wrong JSON types, unknown schema version event id, schema error path
Render missing template variable, unsafe URL, channel length violation template id/version, variable names
Dispatch invalid address, provider refusal, timeout redacted recipient, attempt id, response class

Do not “fix” a phone number by guessing a country, and do not silently turn a missing variable into the string undefined. Those choices create messages that look valid while pointing at the wrong account.

I once found a payload that passed a loose object check because expires_at was a string containing a typo. The later renderer produced a plausible sentence, and the only visible symptom was a link that never expired in the test harness. The fix was not another conditional in the SMS code; it was a strict schema with a format check and a test fixture for the exact bad value (ERR_SCHEMA_PATH=/expires_at).

How should Node.js debug malformed email and SMS payloads with JSON Schema?

Start with a captured event, its schema version, and a correlation id. Reproduce the decision without calling a provider. In Node.js, a validator such as Ajv can report the JSON Pointer path, keyword, and received value; the same diagnostic shape can be emitted by any standards-based validator.

from jsonschema import Draft202012Validator

EVENT_SCHEMA = {
    "type": "object",
    "required": ["event_id", "schema_version", "account_id", "verification_url", "expires_at", "template"],
    "properties": {
        "event_id": {"type": "string", "minLength": 1},
        "schema_version": {"const": 2},
        "account_id": {"type": "string", "minLength": 1},
        "verification_url": {"type": "string", "format": "uri", "pattern": "^https://"},
        "expires_at": {"type": "string", "format": "date-time"},
        "template": {"type": "string", "minLength": 1},
        "variables": {"type": "object", "additionalProperties": {"type": "string"}}
    },
    "additionalProperties": False
}

def validate_event(event):
    errors = sorted(Draft202012Validator(EVENT_SCHEMA).iter_errors(event), key=lambda e: list(e.path))
    if errors:
        return [{"path": "/" + "/".join(map(str, e.path)), "keyword": e.validator} for e in errors]
    return []
Enter fullscreen mode Exit fullscreen mode

The important behavior is deterministic diagnostics, not the library name. Log the schema path and a hash of the payload; redact the URL query and all recipient values. Keep the original event in a short-retention quarantine store so an engineer can inspect it without granting the notification worker write access to the account database.

Then run a render-only test matrix: a syntactically valid email, an E.164 phone number, a malformed email, a phone with an ambiguous local prefix, and a template that references a variable absent from variables. Each case should end in exactly one class. “Invalid recipient” is actionable; “send failed” is not.

Implementation detail: a replay harness finds the real failure.

The signup transaction should commit the account and enqueue an outbox record in one database transaction. A worker reads the outbox, validates the immutable event, renders each requested channel, and writes an attempt record before dispatch. An idempotency key made from event_id, channel, and template version prevents retries from creating a second link or a duplicate message.

def process_outbox(record, validator, renderer, sender, attempts):
    problems = validator(record.event)
    if problems:
        attempts.reject(record.id, "schema", problems)
        return

    for channel in record.channels:
        rendered = renderer.render(channel, record.event)
        recipient = rendered.recipient
        if not renderer.valid_recipient(channel, recipient):
            attempts.reject(record.id, "recipient", {"channel": channel})
            continue
        key = f"{record.event['event_id']}:{channel}:{rendered.template_version}"
        if attempts.exists(key):
            continue
        attempt_id = attempts.start(key, channel)
        result = sender.send(channel, recipient, rendered.body, idempotency_key=key)
        attempts.finish(attempt_id, result.classification)
Enter fullscreen mode Exit fullscreen mode

The worker must not derive a recipient from free-form template text. It should select email or phone_e164 from a typed profile snapshot, and it should record why a channel was skipped. Delivery providers differ in retry semantics, so the adapter returns a small internal vocabulary: accepted, throttled, transient, permanent, or unknown. Unknown is a reason to inspect, not permission to loop forever.

Compare this boundary with a campaign system before expanding its scope.

This boundary is not a universal messaging platform. The catch is that it is not suitable when marketing campaigns need segmentation, unsubscribe workflows, or high-volume fan-out; use a dedicated campaign system and keep signup verification on its own queue. It also does not guarantee inbox placement or handset reachability. Google’s sender guidance requires authentication, low spam rates, and sound domain practices, while NIST’s digital-identity guidance treats the authenticator and its lifecycle as part of the security decision, not as a mere text message.

The rejected option was “render first, validate after the provider responds.” It is tempting because it reduces application code, but it moves a deterministic contract error into an opaque external boundary and makes retries expensive. That pattern still has a valid use case for low-risk, human-authored notifications where losing one message is acceptable and a provider owns the template contract. It is the wrong trade for an account-activation link.

After rollout, measure the boundaries separately.

Keep schema versions immutable. Add a new version when a field changes meaning, and retain the old validator long enough to drain queued events. Alert on the ratio of schema rejects, permanent recipient rejects, and provider-transient attempts separately; one combined “notification error” metric hides the boundary that needs repair.

Store redacted payload samples, template version, validator output, and provider classification together under the correlation id. A replay tool should accept a quarantined event and a fake sender, never a live destination by default. I’m not sure any single delivery metric predicts a user seeing the link, so I pair provider receipts with verification completion and expiration rates, then inspect the gaps manually.

The measurement window matters. A schema reject should be visible within seconds, while a delivery receipt can arrive later and a verification completion may happen hours after signup. Keep those timestamps distinct, and calculate latency from event creation to each state rather than treating a late receipt as a worker failure. For incident review, sample both successful and rejected events; only looking at failures misses accidental truncation, duplicate sends, and links rendered with an old template version.

Three words matter: validate before side effects.

References

Top comments (0)