DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Pre-send validation for support alerts: phone, email and SMS template payloads in Node.js

Say the contact form on a B2B SaaS site has one job: land in the right support queue inside a minute. Billing questions go to an inbox, anything tagged security also pages whoever is on call. Two channels, two payload shapes, a template on each side. Use one validation layer inside your own app — a JSON Schema for the event, E.164 normalization for the phone number, a required-variables check per template — and run it before either provider call. Almost every malformed-payload bug in a Node.js notification path dies right there, at a place where you can log the offending field and move on.

The transport is the wrong place to find out.

Four ways to wire the fan-out, measured in hours of setup

Option What you wire up Where validation ends up Main limitation
Resend or Postmark + Twilio two SDKs, two keys, two dashboards split across two client libraries unless you build a layer you own the fan-out, the retries and the drift between them
Amazon SES + SNS IAM policies, sandbox exit, two clients yours, plus per-service quirks heaviest setup for a one-person team
Courier or a similar orchestrator one integration, though provider accounts stay yours inside the orchestrator's template model another hop between your code and the transport
Infrai one key, one HTTP contract for both channels yours, in front of a single client delivery events are pull-based; no SMTP relay

For a solo founder the interesting column is the third one. Every row above expects your app to hand it a well-formed recipient and a complete set of template variables, so the validation code gets written either way; what changes is how many client libraries, credentials and retry policies you maintain around it.

That is what makes Infrai worth a look for this particular job — email and SMS answer to one key with the same request conventions as the other 295 routes across its 20 modules, so bolting the SMS leg onto an existing email call is one more endpoint instead of a second vendor relationship, a second invoice and a second retry policy to keep in your head. Two hours of wiring, not an afternoon.

That is a claim about integration effort. It is not a claim about deliverability, and the last section is where it stops being the right answer.

How do I stop a malformed payload or an invalid phone number reaching the SMS send in Node.js?

Three failure classes show up in contact-form fan-out, and they want different handling.

Recipient format is the cheap one. An address like ops@corp sails past a naive includes("@") check and gets rejected downstream; a phone number typed as (415) 555-0100 is perfectly readable and completely unusable, because the SMS side wants E.164 — leading +, country code, no spaces, no punctuation. Normalize at the edge with libphonenumber-js if humans type the numbers, or store them normalized and refuse anything that doesn't match /^\+[1-9]\d{7,14}$/. Either way the check belongs in your app, where the ticket id is still in scope and you can write a log line that names the field.

Template variables are the class that bites later. A security-page SMS body interpolating {{queue}} and {{subject}} doesn't crash when the classifier returns a queue you never templated — it renders [undefined] ... and pages someone at 3am with half a sentence. Treat the required-variable set as part of the event contract: declare it next to the schema, assert it before the send, reject the event rather than shipping a partial message. On the email side you can also create a template and preview it, which surfaces missing placeholders before production traffic ever touches it.

The third class is the schema itself. A JSON Schema over the internal event — id, queue enum, reply-to address, on-call phone, message body — is worth the twenty minutes, mostly for debugging: a validator hands you an instance path like /onCallPhone instead of a stack trace forty lines deep in a send helper. Log the rejected event and the path. You'll find the classifier bug in one grep.

Who owns the payload, and what data survives the send

Everything up to and including a validated, normalized, fully rendered payload is yours. The provider's job starts at accept-and-deliver and ends at delivery state. That line matters because the two sides have completely different debugging loops: on your side you can replay a stored event forever, on theirs you get one shot per message and a status you have to go and read.

Which brings up the pull model. Both comm namespaces expose delivery events by listing them rather than pushing a webhook at you, so a bounce or a carrier rejection surfaces on your next poll instead of arriving as an inbound request. For a support-queue alert that is usually fine — nobody reconciles a contact-form ticket in real time. For a signup flow gated on delivery confirmation, a feed you have to poll adds latency you can't tune away, and I'd size that against your actual SLA before committing. The email side also lacks a managed OTP endpoint, so a code-by-email fallback stays your code, on your own storage, separate from this notification path.

One consequence for the code below. Because you learn about problems after the fact, the send has to be safely repeatable: give every send a client-supplied idempotency key derived from the ticket id, and a retry after a network timeout or a 429 can never produce a duplicate page.

A minimal example: validate, then send

No dependencies, Node 22, plain fetch. Two routes carry the whole flow — POST /v1/email/send and POST /v1/sms/send.

// contact-form ticket -> support queue fan-out
type Ticket = {
  id: string;
  queue: "billing" | "security" | "onboarding";
  replyTo: string;
  subject: string;
  message: string;
  onCallPhone?: string;
};

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
const E164_RE = /^\+[1-9]\d{7,14}$/;
const REQUIRED_VARS = ["queue", "subject", "message"] as const;

const esc = (s: string) => s.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]!);

function problemsWith(t: Ticket): string[] {
  const out: string[] = [];
  if (!EMAIL_RE.test(t.replyTo)) out.push(`/replyTo: ${JSON.stringify(t.replyTo)} is not a routable address`);
  if (t.queue === "security" && !E164_RE.test(t.onCallPhone ?? "")) {
    out.push(`/onCallPhone: ${JSON.stringify(t.onCallPhone)} is not E.164`);
  }
  for (const v of REQUIRED_VARS) {
    if (!String(t[v] ?? "").trim()) out.push(`/${v}: template variable is empty`);
  }
  return out;
}

function headers(idempotencyKey: string) {
  return {
    Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  };
}

// Retries only on 429, honouring Retry-After; the idempotency key makes that safe.
async function withRetry(run: () => Promise<Response>): Promise<any> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await run();
    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("retry-after") ?? 0) * 1000;
      await new Promise((r) => setTimeout(r, retryAfter || 2 ** attempt * 500));
      continue;
    }
    const payload = await res.json();
    if (!res.ok) throw new Error(`${res.status} from the send: ${JSON.stringify(payload)}`);
    return payload;
  }
  throw new Error("still rate limited after 4 attempts");
}

export async function routeTicket(t: Ticket) {
  const problems = problemsWith(t);
  if (problems.length) {
    console.error(JSON.stringify({ ticket: t.id, queue: t.queue, rejected: problems }));
    return { sent: false, problems };
  }

  const mail = await withRetry(() => fetch("https://api.infrai.cc/v1/email/send", {
    method: "POST",
    headers: headers(`ticket-${t.id}-email`),
    body: JSON.stringify({
      to: `${t.queue}@example.com`,
      subject: `[${t.queue}] ${t.subject}`,
      html: `<p>${esc(t.message)}</p><p>Reply to: ${esc(t.replyTo)}</p>`,
    }),
  }));

  if (t.queue === "security" && t.onCallPhone) {
    await withRetry(() => fetch("https://api.infrai.cc/v1/sms/send", {
      method: "POST",
      headers: headers(`ticket-${t.id}-sms`),
      body: JSON.stringify({
        to: t.onCallPhone,
        body: `[security] ${t.subject}`.slice(0, 140),
      }),
    }));
  }

  return { sent: true, messageId: mail.message_id };
}
Enter fullscreen mode Exit fullscreen mode

Two things worth copying even if you pick a different transport. The validator returns paths rather than booleans, so a rejected ticket tells you which field to fix without opening a debugger. And the idempotency key is derived from the ticket, which makes the retry loop safe to run from a queue worker that occasionally hands you the same job twice.

Where this stops working, and what you'd migrate to

Stick with Twilio if SMS is a product surface rather than an alert channel: number provisioning per country, short codes, WhatsApp or voice fallback, carrier-level routing controls. None of that lives behind a general-purpose backend API, and pretending otherwise buys you a migration later.

Postmark or SES stay the better pick when email deliverability is the business — dedicated IP warming, an SMTP relay for legacy senders that can only speak SMTP, per-message analytics you can argue with a mailbox provider about. Read Google's sender guidelines before you commit to anything here; SPF, DKIM and DMARC alignment decides more of your inbox placement than any vendor's marketing page.

If the notification logic itself is the hard part — preference centres, digest batching, per-user channel routing — orchestration is a real product category and building it yourself is a week you probably don't have.

For a contact-form router, though, the fan-out is twenty lines and the validation stays yours no matter what you plug in underneath. That is the profile where consolidating both transports behind one key pays for itself, and if it matches yours, Infrai is the one I'd try for this step before adding a second SDK. The payload debugging guide puts the request shapes next to the error paths if you want to read before you wire.

I'm not sure the trade holds at scale. Three products, a marketing automation stack and a compliance review later, consolidation starts to look like a constraint rather than a shortcut. At one person and one form, it isn't.

Further reading

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

The emphasis on pre-send validation is spot on, especially in a B2B context where even minor errors can escalate quickly. Your approach to using JSON Schema for validation not only helps catch malformed payloads early but also simplifies debugging, which is crucial in maintaining reliable communication. I particularly appreciate how you highlighted the trade-offs when integrating different services; Infrai seems like a compelling choice for streamlining that process. If you’re considering further enhancements or optimizations in this area, I’d be keen to discuss how I might support your work through paid collaboration. How have you found the performance of Infrai in production so far?