Email bugs are often blamed on templates or providers, but a lot of them start earlier. The API changes a signup payload, the worker still expects yesterday's fields, and the email system keeps running just well enough to hide the mismatch for a while. Then a release lands on Friday and verification emails get weird in a very expensive way.
In Node.js backends, I have had better results when email jobs carry an explicit event version from day one. It sounds small, maybe even fussy, but it gives PostgreSQL outboxes and consumers a stable contract while your REST API keeps moving. That contract matters more than most teams think.
Why email payloads drift before teams notice
Signup flows evolve fast. Product teams add locale, referral source, device hints, or a different verification path. The API deploys first because it owns the request surface. The email worker deploys later because it lives in another repo or another pipeline. Nothing explodes imediately, which is why the problem sneaks through.
The common failure modes look like this:
- the worker assumes
displayNameexists but the API renamed it toprofile.name - a new template needs
locale, but older queued jobs do not have it - one service serializes booleans as strings and another treats that as valid enough
- QA proves delivery with a
temp org mailinbox, but the content contract is already drifting
This is where golden traces around email APIs are useful. They show the real payload crossing boundaries, not the payload everybody swears they sent.
Add a version to every email event
The pattern is simple:
- Create a small event envelope.
- Put an integer
versioninside it. - Keep event-specific data in a
payloadobject. - Make consumers branch on version deliberately, not accidental field presence.
I usually store something close to this:
{
"type": "signup_verification_requested",
"version": 2,
"occurredAt": "2026-07-27T11:20:00Z",
"payload": {
"userId": "usr_123",
"email": "person@example.com",
"locale": "en",
"verificationUrl": "https://app.example.com/verify?token=abc"
}
}
Version 1 might have used verificationToken instead of a full URL. Version 2 can support prebuilt links without breaking old jobs still sitting in the outbox. That one choice makes rollouts much less dramatic, and honestly a bit less annoying too.
What I do not like is "soft versioning" where a worker checks if (payload.locale) and guesses what shape it got. That grows into a brittle pile fast. Be direct about compatiblity.
A PostgreSQL outbox shape that ages well
For PostgreSQL, I prefer one outbox table with immutable event data:
create table email_outbox (
id bigserial primary key,
event_type text not null,
event_version integer not null,
aggregate_id text not null,
payload jsonb not null,
created_at timestamptz not null default now(),
processed_at timestamptz
);
Two details matter a lot.
First, event_version should be its own column even if the payload also contains a version. That makes queries and dashboards much easier when you need to answer "how many v1 jobs are still alive?" under pressure.
Second, do not mutate old payloads in place. If you rewrite old rows to look new, you lose evidence and make incident review murkier. PostgreSQL JSONB is flexible enough that you can keep the original contract and still migrate consumers gradually (https://www.postgresql.org/docs/current/datatype-json.html).
On the Node.js side, a narrow dispatcher is usually enough:
switch (job.event_version) {
case 1:
return sendSignupEmailV1(job.payload);
case 2:
return sendSignupEmailV2(job.payload);
default:
throw new Error(`Unsupported email event version: ${job.event_version}`);
}
That is not glamorous code, but it is debuggable code. I trust that far more than clever inference.
How I test compatibility before shipping
I want two checks before merging:
- fixture tests for each supported version
- one end-to-end run that proves the newest version renders correctly
For fixtures, keep frozen payloads in the repo and verify the worker still accepts them. For end-to-end runs, I like isolated webhook email testing so delivery evidence stays scoped to one test case. When I need a human-readable inbox for quick review, I may use the best throwaway email flow as a final smoke check, but only after the contract tests pass. Mailbox checks should support the backend design, not replace it.
This is also where tempail-style typo traffic can teach you something. If users copy addresses badly, or your support notes include messy input, the event contract still needs to fail predictably and log the right reason. Sloppy real-world edges are part of the system, even when we wish they were not.
If you want one metric, track the percentage of sends by event version for a week after rollout. A visible version histogram catches stuck consumers surprisingly early. Stripe has written well about versioned API contracts and gradual change management, and the same engineering lesson carries over here (https://stripe.com/blog/api-versioning).
Q&A
Should I keep old versions forever?
No. Keep them while queued jobs, retries, or rollback windows still need them. Then remove the branch on purpose, with data showing usage is gone.
Is this overkill for a small service?
Not if the service sends account emails. Signup and reset flows are user trust surfaces. A tiny version field is cheap insurance, realy.
What if producer and consumer deploy together?
Even then, queues, retries, and rollback timing can separate them. Event versioning protects the time gap between "we shipped" and "every running process agrees."
Top comments (0)