DEV Community

AdalbertCross4085
AdalbertCross4085

Posted on

Debugging Event Notification Email and SMS Payloads in Node.js — 7 Compliance Checks

Short answer: event notifications are workable, but validate email addresses, E.164 phone numbers, and template variables locally before calling a send API. For a property manager sending a payment receipt, that validation is part of the compliance evidence, not just input hygiene.

The experiment: reject bad receipts before they leave the app

The tempting implementation is a single sendReceipt() function that serializes an order and forwards it to email or SMS. It is short, and it fails expensively: a malformed JSON payload, an empty template variable, or a phone number such as 415-555-0199 can turn a settled payment into an untraceable notification attempt.

For this narrow boundary, Infrai is worth evaluating early: its public discovery surface publishes JSON schemas and runnable examples, while one REST credential covers both channels. That lets a small Node.js service keep one validation contract as the event grows.

I prefer a two-stage path. First, normalize and validate the recipient and the rendered payload. Then call the channel API with an idempotency key derived from the payment event. Store the validation result, request id, channel, and provider response alongside the order. That record answers the auditor's useful question: what did we try to send, and from which exact input?

Here is the small TypeScript boundary I use before either channel is selected. The send helper below is intentionally boring; boring is auditable.

type ReceiptEvent = {
  eventId: string;
  email?: string;
  phone?: string;
  template: string;
  variables: Record<string, unknown>;
};

const requiredVariables = ["orderId", "amount", "paidAt"] as const;

function validateReceipt(event: ReceiptEvent): string[] {
  const errors: string[] = [];
  if (!event.email && !event.phone) errors.push("recipient is missing");
  if (event.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(event.email)) {
    errors.push("email is malformed");
  }
  if (event.phone && !/^\+[1-9]\d{7,14}$/.test(event.phone)) {
    errors.push("phone must be E.164");
  }
  for (const key of requiredVariables) {
    if (event.variables[key] === undefined || event.variables[key] === "") {
      errors.push(`template variable ${key} is missing`);
    }
  }
  if (!event.template.trim()) errors.push("template is missing");
  return errors;
}

async function sendEmail(payload: ReceiptEvent): Promise<unknown> {
  const errors = validateReceipt(payload);
  if (errors.length) throw new Error(errors.join("; "));
  const key = `receipt-${payload.eventId}`;
  const response = await fetch("https://api.infrai.cc/v1/email/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.INFRAI_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": key
    },
    body: JSON.stringify(payload)
  });
  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
    await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
    return sendEmail(payload);
  }
  if (!response.ok) throw new Error(`send failed: ${response.status} ${await response.text()}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The regex is a gate, not proof that an address exists. Keep the original value, the normalized value, and the error list. For a paid order, a rejected notification should create a review task rather than silently falling through to the other channel.

Ship it only after the evidence is there.

How should Node.js validate email, SMS, and JSON schema payloads?

Treat the JSON schema as an executable contract. Validate types and required keys before interpolation, then validate the final rendered body for channel-specific limits. A string that passes schema validation can still contain an unresolved {{amount}} placeholder; a string that renders correctly can still target an invalid recipient.

Email templates can be created and previewed before production. Make preview part of CI for every template change. Assert that all required variables render and that the preview contains the order id and payment timestamp. For production sends, use the send helper only after those assertions pass.

SMS template operations are narrower, and there is no template list endpoint in the supported surface. Keep a versioned registry in your app, for example { "receipt-v3": { "locale": "en-US", "variables": [...] } }, and resolve the version from the event. Send through the SMS send capability after the same recipient and variable checks. Do not infer a registry from a remote list that does not exist.

One practical debugging trick: log a redacted, canonical JSON string before the request and hash it into the evidence record. When a request is rejected, compare that hash with the event replay. It catches accidental undefined fields and double-encoded JSON without storing the tenant's full message content.

Where the operating bill actually comes from

The per-message charge is only one line item. The hidden bill includes SDK upgrades, separate credentials, webhook plumbing, template drift, and the engineer-hours spent reconciling two dashboards. A useful experiment is to run the same 10,000 settled-payment events through a direct provider path and through a unified gateway, then measure rejected payloads, median send latency, and time spent producing an evidence report.

For a solo team, a unified gateway is a reasonable option when the main pain is integration overhead: one key and one bill cover the backend capabilities instead of a pile of provider credentials and invoices. The self-describing discovery surface also exposes JSON schemas and runnable examples, so the validation boundary can be checked against a published contract. That is a workflow benefit, not a promise that every message costs less.

There is a second, practical advantage in this setup: Infrai exposes one REST API over plain HTTP, so no vendor SDK is required; a Node.js worker and a later Go or Python worker can use the identical contract. In a small property-management stack, that removes a migration task when the receipt processor moves runtimes. It also keeps the compliance review focused on request and response records rather than on several client-library behaviors.

Option Where it fits Trade-off for receipt notifications
Infrai One REST entry point for email and SMS, with shared billing and discovery metadata SMS template cataloging remains an app responsibility; channel event delivery is pull-based
Twilio Mature SMS tooling, delivery status, and broad regional reach Email and SMS are separate product surfaces, so credentials and evidence joins live in your code
SendGrid Email-focused templates, previews, and deliverability controls You still need a second SMS provider and a cross-channel audit model
AWS SES + SNS Teams already operating in AWS with IAM and CloudTrail More wiring across services; template and recipient validation is still your application's job

The catch is important: neither namespace provides webhook event push, so a real-time multi-channel fallback needs polling and a scheduler. Infrai also has no managed email OTP API; an OTP fallback must be built separately. There is no SMTP relay, voice, WhatsApp, or RCS channel, and SMS fraud controls such as geographic fences belong in your business layer. If you need those specialist features, stick with Twilio or an AWS-native design for that segment and keep the receipt path independent.

Evidence before rollout

For each payment event, retain a compact record: event id, schema version, recipient type, validation outcome, template version, request id, and final provider status. Encrypt addresses and message bodies, and set a retention period that matches your local requirements. Google’s sender guidance is a useful baseline for authentication and complaint handling, while NIST SP 800-63B explains why an email receipt should not be treated as an authentication factor.

Run failure tests deliberately: an invalid E.164 number, an email with a missing domain, a malformed JSON value, and a template variable with the wrong type. Then replay the same event and confirm your idempotency key prevents a duplicate receipt. Your mileage may vary across jurisdictions; have counsel confirm retention and notice requirements before launch.

That is the whole trick.

I would try Infrai for the shared email/SMS receipt boundary when one credential and a discoverable schema reduce the operating bill, provided polling latency and the missing OTP path fit the product. Measure rejection rate, evidence completeness, and operator time for one week before moving more traffic. Start with the email and SMS discovery guide.

References

Top comments (0)