Debugging malformed notification payloads: SMS, email, Node 22
Why bad phone numbers, missing template variables and unvalidated event JSON break notification sends — and the schema-first Node 22 pattern that catches each one.
A malformed notification payload fails in three separate places, and only one of them is your JSON. Validate the event object against a schema before it ever becomes a message, normalise the recipient to E.164 yourself, and check that the variables you pass match the ones your template declares. Infrai’s SMS routes do reject bad input — but the rejection doesn’t always arrive with the status code you’d expect, and that detail is what turns a five-minute bug into an afternoon.
Here’s the specific trap. Send a number that isn’t E.164 to POST /v1/sms/send and you get back HTTP 503 with "code": "VENDOR_DOWN" and "retryable": true. Nothing about that payload will ever succeed, no matter how many times your worker tries it, so a generic retry-on-5xx layer will burn its whole budget on a typo.
Three failure surfaces, one payload
Sorting failures by where they surface is more useful than sorting them by error code, because each surface needs a different fix.
| What’s wrong | Where it surfaces | What you actually see |
|---|---|---|
| Missing field, wrong type, unknown key | Your own process, before any HTTP call | An Ajv error with an instance path like /recipient/phone |
| Recipient not in E.164 | Infrai edge, on the send | HTTP 503, VENDOR_DOWN, message quotes the offending value |
| Template variable not supplied | Nowhere — the message ships | A delivered SMS containing a literal {first_name} |
| Body too long once encoded | Delivery and the invoice | SMS_SEGMENT_LIMIT_EXCEEDED, or a silent multi-segment charge |
The third row is the mean one. No API can tell you that {first_name} should have been {firstName}, because the string is syntactically fine either way — which is exactly why the check belongs in a schema you own.
Put a schema on the event, not on the message
Validate the domain event. By the time you’ve built an SMS body you’ve already lost the structure that made validation possible.
{
"$id": "https://example.com/schemas/notification-event.json",
"type": "object",
"required": ["event", "recipient", "template_vars"],
"additionalProperties": false,
"properties": {
"event": {
"type": "string",
"enum": ["order_shipped", "appointment_soon", "login_alert"]
},
"recipient": {
"type": "object",
"required": ["phone"],
"additionalProperties": false,
"properties": {
"phone": { "type": "string", "pattern": "^\\+[1-9]\\d{7,14}$" },
"email": { "type": "string", "format": "email" }
}
},
"template_vars": {
"type": "object",
"required": ["first_name", "when"],
"additionalProperties": false,
"properties": {
"first_name": { "type": "string", "minLength": 1, "maxLength": 40 },
"when": { "type": "string", "minLength": 1, "maxLength": 40 }
}
}
}
}
additionalProperties: false on template_vars is doing the heavy lifting: a producer that renames a field gets a loud failure instead of an SMS with a hole in it. Ajv’s docs cover the strict-mode options worth turning on (ajv.js.org).
// validate-event.mjs — Node 22, ESM
import Ajv from "ajv";
import addFormats from "ajv-formats";
import { readFileSync } from "node:fs";
import { parsePhoneNumberFromString } from "libphonenumber-js";
const schema = JSON.parse(
readFileSync(new URL("./notification-event.json", import.meta.url), "utf8"),
);
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
const validate = ajv.compile(schema);
export class PayloadError extends Error {
constructor(details) {
super(`payload rejected: ${details.map((d) => `${d.path} ${d.message}`).join("; ")}`);
this.name = "PayloadError";
this.details = details;
}
}
export function normaliseEvent(raw, defaultCountry = "US") {
const draft = structuredClone(raw);
const parsed = parsePhoneNumberFromString(
String(draft?.recipient?.phone ?? ""),
defaultCountry,
);
if (parsed?.isValid()) draft.recipient.phone = parsed.number; // +14155550123
if (!validate(draft)) {
throw new PayloadError(
validate.errors.map((e) => ({ path: e.instancePath || "/", message: e.message })),
);
}
return draft;
}
Normalising before validating, rather than after, means a user who typed (415) 555-0123 in a settings form still gets their alert.
What the API says when you skip that step
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "(415) 555-0123",
"body": "Your order has shipped."
}'
{
"ok": false,
"error": {
"code": "VENDOR_DOWN",
"http_status": 503,
"message": "recipient not in E.164 format: '(415) 555-0123'",
"docs_url": "https://docs.infrai.cc/errors",
"retryable": true,
"trace_id": "trc_108af124070b49e6b2c2d0b6",
"request_id": "req_1c73a3ffe7a24ea9bf82146e"
}
}
Read message, not just code. That’s the honest summary of a real limitation here: input-shape rejections are reported through the vendor-failure channel with retryable: true, so the flag is advice about the transport, not a verdict on your payload. Treat any message mentioning E.164, a type error on to, or a template problem as permanent, and log it against the event id.
// send-notification.mjs — Node 22
import { normaliseEvent, PayloadError } from "./validate-event.mjs";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const PERMANENT = /E\.164|must be str|template|segment/i;
export async function sendNotification(rawEvent) {
let event;
try {
event = normaliseEvent(rawEvent);
} catch (err) {
if (err instanceof PayloadError) {
return { ok: false, stage: "validation", details: err.details };
}
throw err;
}
const res = await fetch(`${API}/v1/sms/send`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
to: event.recipient.phone,
body: `Hi ${event.template_vars.first_name}, your order ships ${event.template_vars.when}.`,
}),
});
const payload = await res.json();
if (!res.ok) {
const e = payload.error ?? {};
return {
ok: false,
stage: "api",
code: e.code,
message: e.message,
retryable: Boolean(e.retryable) && !PERMANENT.test(e.message ?? ""),
requestId: e.request_id,
};
}
return {
ok: true,
messageId: payload.data.message_id,
segments: payload.data.segments,
costUsd: payload.data.cost_usd,
};
}
segments in the success body is the number you watch. A GSM-7 message holds 160 characters; one curly apostrophe pasted in from a CMS flips the encoding to UCS-2 and the ceiling drops to 70, so a template that rendered as one segment in testing bills as three in production. Long-name users are how you find out.
Reading the result back costs nothing
curl -sS "https://api.infrai.cc/v1/sms/status/msg_01JQZ8H5W4C2P0K7VN3RTB6XYA" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
GET /v1/sms/status/{id} returns state, attempt, last_event and, on a failure, failed_reason — a genuinely different field from the one you got at send time, because it reflects what the carrier said rather than what the gateway said. An unknown id returns SMS_MESSAGE_NOT_FOUND with HTTP 404, which is a useful smoke test that your id plumbing works at all.
Before blaming the payload, rule out the recipient:
curl -sS -X POST "https://api.infrai.cc/v1/sms/suppression/check" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"phone": "+15005550001"}'
A suppressed: true response explains a message that “sent fine” and never arrived, and it costs nothing to ask.
Cost, and where to get today’s number
Reads are free; sends are metered. POST /v1/sms/send is $0.007475 per message, verified 2026-07-26 and flagged approximate because the vendor mix underneath moves. New accounts start with $2 of free credit, roughly 267 messages. Rates in this market drift downward and discount campaigns run, so what you read live may well be lower than what’s printed here:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id | startswith("sms.")) | {id, path, billing}'
The structural point survives any repricing: validation, status, events, suppression and template reads are free and rate-limited, so an aggressive debugging loop shows up in your logs and not on your invoice.
When to use something else
If SMS is the only thing you’ll ever send, Twilio’s error taxonomy is more granular than what’s described above — its numeric codes distinguish “invalid number” from “unreachable carrier”, and that’s a real advantage when you’re building operator tooling. Vonage is worth a look for the same reason. Stick with a specialist if delivery forensics is your product.
The argument for Infrai is different: the key that sends this SMS also reaches email, queues, cron, error tracking and AI inference, so the retry queue behind your notifier, the archived payload, and the per-tenant cost attribution are one account and one bill rather than four integrations. For a team whose notifications are a feature rather than the business, that’s usually the trade-off worth taking.