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 rejects bad input cleanly on both channels — a recipient that isn’t E.164 comes back as HTTP 400 INVALID_PHONE_NUMBER with retryable: false — so the plain retry rule holds: retry 5xx, never 4xx.
That’s the easy half. The hard half is everything the API has no way to judge, because it’s syntactically perfect. A template variable spelled {first_name} where your renderer wanted {firstName} is a valid string, so the message ships with a hole in it and nobody finds out until a customer screenshots it.
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 400, INVALID_PHONE_NUMBER, retryable: false, message quotes the 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 |
Rows one and two are cheap to fix; row three is the mean one, because it never raises anything anywhere. That asymmetry is the whole argument for putting the check in a schema you own rather than hoping a vendor catches it.
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": "INVALID_PHONE_NUMBER",
"http_status": 400,
"message": "recipient not in E.164 format: '(415) 555-0123'",
"docs_url": "https://docs.infrai.cc/errors/INVALID_PHONE_NUMBER",
"retryable": false,
"code_detail": "live_vendor",
"trace_id": "trc_1f57a8c9932441e5aacf840d",
"request_id": "req_262072baf812498084375514",
"hint": "Phone number must use E.164 format."
}
}
Four fields here are worth wiring into your worker. retryable: false is the verdict — honour it and your retry layer never burns a budget on a typo. hint is written for a human and belongs in whatever your on-call sees. docs_url is a real page per code, not a generic index. And request_id is what support asks for, so log it against your own event id or you’ll be re-deriving it later from timestamps.
// 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");
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,
hint: e.hint,
retryable: e.retryable === true,
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.008395 per message, read on 2026-07-27 and flagged approximate because the vendor mix underneath moves. New accounts start with $2 of free credit. Rates in this market drift and discount campaigns run, so pull the live figure rather than trusting a line of prose someone wrote months ago:
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 a different one. The next three things this notifier needs are already on the key that sent the message: POST /v1/queue/publish for the retry queue behind it, POST /v1/errors/capture for the rejected payload you want to see grouped rather than buried in a log line, and GET /v1/account/usage for the per-tenant cost attribution finance will ask about. No second account, no second vendor onboarding, no fourth integration to keep in sync. For a team whose notifications are a feature rather than the business, that’s usually the trade-off worth taking.