Gaming Event Notifications: How to Stop Malformed JSON Email API Requests
TL;DR: Put a small validation-and-rendering boundary in front of the email API. Parse JSON once, reject unknown or missing template variables, escape user-controlled values, render the exact template version, and record
TL;DR: Put a small validation-and-rendering boundary in front of the email API. Parse JSON once, reject unknown or missing template variables, escape user-controlled values, render the exact template version, and record a token-free evidence event before delivery. For a gaming password reset, that is the least complex design that turns an opaque 400 Bad Request into a local, reproducible result while preserving evidence of what the system attempted to send.
The flow is short: the game account service creates a single-use reset token with a short expiry, passes typed data to a notification boundary, and receives either a precise validation error or a rendered message plus an evidence record. Only the validated result reaches a delivery adapter. Preview uses the same rendering function as production, so it cannot quietly accept a different set of variables.
How should an email API handle malformed JSON event notifications?
An HTTP 400 only says that the receiving server considers the request invalid. It does not tell you which boundary failed. In this workflow, three failures often collapse into that one response: the outer request is not valid JSON, the JSON is syntactically valid but lacks a required variable, or the rendered HTML is structurally valid while displaying unsafe or unintended content.
Keep those categories separate. A truncated JSON string should fail during parsing. A payload without expiresAt should fail schema validation. A display name containing markup should remain text after rendering. If all three are delegated to a remote email API, the feedback arrives late and may vary across delivery adapters.
Fail locally.
There is another trap. JavaScript object interpolation can turn undefined into visible text without throwing, so a preview may look superficially complete while the reset instructions are broken. The correct acceptance condition is stricter: every required key is present with the expected type, no unexpected key crosses the boundary, the expiry is still in the future, and the renderer has one escaping rule for all untrusted text.
Build the boundary before calling any delivery API
The following TypeScript file is runnable on a current Node.js setup with TypeScript execution enabled. It uses only Node APIs. The example chooses a ten-minute expiry as an application policy, not as a universal security constant; the important property is that issuance and expiry are recorded explicitly and enforced by the account service when the token is redeemed.
import { createHash, randomBytes } from "node:crypto";
type ResetVariables = {
playerName: string;
resetUrl: string;
expiresAt: string;
};
type RenderEvidence = {
eventType: "password_reset.rendered";
templateId: "password-reset";
templateVersion: 3;
occurredAt: string;
variableNames: Array<keyof ResetVariables>;
renderedSha256: string;
outcome: "accepted";
};
const requiredKeys = ["playerName", "resetUrl", "expiresAt"] as const;
function parseVariables(rawJson: string): ResetVariables {
let value: unknown;
try {
value = JSON.parse(rawJson);
} catch (error) {
const detail = error instanceof Error ? error.message : "unknown parse error";
throw new Error(`payload_json_invalid: ${detail}`);
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("payload_shape_invalid: expected an object");
}
const record = value as Record<string, unknown>;
const unknownKeys = Object.keys(record).filter(
(key) => !requiredKeys.includes(key as keyof ResetVariables),
);
if (unknownKeys.length > 0) {
throw new Error(`payload_keys_unknown: ${unknownKeys.join(",")}`);
}
for (const key of requiredKeys) {
if (typeof record[key] !== "string" || record[key].length === 0) {
throw new Error(`template_variable_invalid: ${key}`);
}
}
const variables = record as ResetVariables;
const resetUrl = new URL(variables.resetUrl);
if (resetUrl.protocol !== "https:") {
throw new Error("reset_url_invalid: HTTPS is required");
}
const expiryMs = Date.parse(variables.expiresAt);
if (!Number.isFinite(expiryMs) || expiryMs <= Date.now()) {
throw new Error("expiry_invalid: expected a future ISO timestamp");
}
return variables;
}
function escapeHtml(value: string): string {
return value.replace(
/[&<>"']/g,
(character) =>
({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[character]!,
);
}
function renderPasswordReset(variables: ResetVariables): string {
const playerName = escapeHtml(variables.playerName);
const resetUrl = escapeHtml(variables.resetUrl);
const expiresAt = escapeHtml(variables.expiresAt);
return `<!doctype html>
<html lang="en">
<body>
<p>Hello ${playerName},</p>
<p>A password reset was requested for your game account.</p>
<p><a href="${resetUrl}">Reset your password</a></p>
<p>This link expires at ${expiresAt}.</p>
<p>If you did not request this, you can ignore this message.</p>
</body>
</html>`;
}
function prepareResetEmail(rawJson: string): {
html: string;
evidence: RenderEvidence;
} {
const variables = parseVariables(rawJson);
const html = renderPasswordReset(variables);
const occurredAt = new Date().toISOString();
return {
html,
evidence: {
eventType: "password_reset.rendered",
templateId: "password-reset",
templateVersion: 3,
occurredAt,
variableNames: [...requiredKeys],
renderedSha256: createHash("sha256").update(html).digest("hex"),
outcome: "accepted",
},
};
}
const issuedAt = new Date();
const expiresAt = new Date(issuedAt.getTime() + 10 * 60 * 1000);
const token = randomBytes(32).toString("base64url");
const rawJson = JSON.stringify({
playerName: "Avery & Co.",
resetUrl: `https://accounts.example/reset?token=${token}`,
expiresAt: expiresAt.toISOString(),
});
const prepared = prepareResetEmail(rawJson);
console.log(prepared.html);
console.log(prepared.evidence);
Run the file locally and inspect both outputs. Then change expiresAt to expiry, remove the final brace from a literal JSON fixture, and set playerName to <img src=x onerror=alert(1)>. Those three tests should produce, respectively, an unknown-key error, a parse error, and escaped text in the HTML. No network request is needed to diagnose them.
The sample deliberately does not place the token, recipient address, raw variables, or rendered body in the evidence object. The SHA-256 digest can later show that two retained render artifacts are identical, but a digest alone does not prove delivery or prove what a person saw. Delivery acceptance, bounce processing, token redemption, and account changes need their own events and retention rules.
Make preview and production share one renderer
A preview route is useful only if it exercises parseVariables and renderPasswordReset from the production path. A second βfriendlyβ preview template creates a false confidence loop: designers approve one artifact while users receive another. Keep the delivery adapter downstream of the renderer and pass it an already prepared subject, HTML body, recipient, and idempotency key.
This boundary also protects portability. Delivery systems differ in request envelopes and error bodies, but the application should not let those differences define its template contract. A narrow adapter can translate the prepared message into a provider request and normalize the result into accepted, temporarily failed, or permanently rejected. The domain event remains stable if the adapter changes. Do not retry a 400 automatically. The same invalid payload is unlikely to improve on its second attempt, and retries can bury the original evidence under duplicate noise. Retry decisions belong to the normalized result: malformed input and policy rejection are permanent; timeouts and explicitly transient server responses may be retryable, with a stable idempotency key and a bounded policy. For compliance evidence, record transitions rather than one oversized log line. A useful chain contains a request correlation ID, template ID and immutable version, validation outcome, render digest, delivery-adapter outcome, and timestamps. Keep secrets and full reset URLs out. Access to the evidence store should be narrower than access to ordinary application logs, and retention should follow the obligations that actually apply to the game and its players rather than an arbitrary βkeep everythingβ default. This approach has a real limitation: owning validation and rendering means owning template-version migrations, escaping tests, and the preview surface. It is not suitable when a nonengineering team must edit and publish templates independently inside an external system. In that case, keep the typed event contract locally but use that system's preview and strict-variable validation as the rendering authority, then capture its immutable template identifier and result in the evidence chain. The trade-off is less rendering control in exchange for an editorial workflow the application does not have to build.
That boundary matters.
Test the failure modes that matter
Start with deterministic fixtures. One valid fixture should render to an approved snapshot. Separate negative fixtures should cover truncated JSON, arrays where an object is expected, missing keys, empty strings, unknown keys, non-HTTPS URLs, invalid timestamps, expired timestamps, and HTML metacharacters in every user-controlled field. A snapshot is evidence of a reviewed change, not a substitute for assertions: also verify that the output contains no raw <script, no literal undefined, and no reset token in the evidence record.
Then test the adapter contract without sending mail. Assert that a prepared message maps to the expected remote envelope and that remote 400 responses become permanent failures with a sanitized diagnostic. Integration tests can use a controlled mailbox, but they should prove a smaller set of facts: the accepted request uses the intended template version, links retain their query parameters, and the delivered MIME message has the expected text and HTML parts.
Rendering is cheap compared with a network round trip, so reject locally first. This is also the cost-conscious path: invalid events consume neither delivery attempts nor retry capacity. Measure validation failures by reason code and template version, while keeping variable values out of metric labels. A sudden rise in template_variable_invalid after a deployment points directly to a producer-contract mismatch.
Ship with an evidence-focused operating rule
Before deployment, assign ownership for the variable contract, pin the template version in code, and review the evidence fields with whoever owns security and compliance. In staging, replay valid and invalid fixtures through the same executable path used in production. At release time, alert on validation failures and permanent adapter rejections separately; they demand different responses. During an incident, search by correlation ID, compare the recorded template version and render digest, and inspect the sanitized adapter response without exposing the reset URL.
The decision rule is blunt: do not send unless the exact production renderer can produce a complete artifact and a secret-free evidence event locally. This does not certify compliance by itself. It does make each claim testable: which contract passed, which template rendered, when it happened, and what the delivery boundary reported.
Further reading
- RFC 8259, The JavaScript Object Notation (JSON) Data Interchange Format: https://www.rfc-editor.org/rfc/rfc8259
- OWASP Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- OWASP Cross Site Scripting Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- Node.js Crypto documentation: https://nodejs.org/api/crypto.html
- Amazon SES documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Twilio SMS character limits and segmentation: https://www.twilio.com/docs/glossary/what-sms-character-limit
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.