Claude Code with Postmark: Transactional Email With Templates
Why Postmark without CLAUDE.md damages your sending reputation
Postmark is a transactional-only email provider. The whole architecture is built around protecting sender reputation: separate message streams for transactional vs broadcast, automatic suppression list management for bounces and complaints, no marketing-blast features in the API, and aggressive rejection of any sender that lets bounce rates climb above 4 percent. This is the differentiator. Postmark trades flexibility for deliverability, and the trade-off is enforced by the platform.
Claude Code without explicit constraints will generate Postmark code that compiles and sends emails successfully but violates the constraints that the platform is built around. The most common patterns: sending broadcast-style email through the transactional stream, ignoring bounce webhooks until the deliverability rate is degraded enough to trigger an account review, hardcoding the server token across multiple services so a leak compromises all of them, and using the wrong template variable syntax for Mustache vs MailerLite-style templates.
This guide covers the CLAUDE.md template that locks Claude Code into Postmark's correct model: one server token per environment, transactional and broadcast on separate streams, template variables with the exact {{variable}} Mustache syntax, bounce and complaint webhooks wired in from day one, and signature verification on every webhook. For the broader email landscape, Claude Code with Resend covers the closest competing service with a different design philosophy.
The Postmark CLAUDE.md template
The CLAUDE.md at your project root needs to declare: the SDK version, the server token environment variable, the message stream IDs, the template ID conventions, the from-address policy, the webhook endpoints, and the hard rules that block the mistakes Claude makes most often.
# Postmark email rules
## Stack
- postmark ^4.x (Node.js SDK)
- TypeScript 5.x strict
- POSTMARK_SERVER_TOKEN in .env.local (never hardcode, NEVER share across environments)
- Default from address: hello@yourdomain.com (verified Sender Signature)
## Project structure
- src/lib/postmark.ts , Postmark client singleton
- src/lib/email-templates.ts , template ID constants and variable type definitions
- src/lib/send-email.ts , wrapper around client.sendEmailWithTemplate
- src/app/api/email/ , route handlers that call sendEmail()
- src/app/api/webhooks/postmark/ , bounce, complaint, delivery webhook handlers
## Message streams (NON-NEGOTIABLE)
Postmark separates email into streams for reputation reasons:
- outbound (default transactional stream) , receipts, alerts, password resets
- broadcast (created manually in dashboard) , announcements, marketing
- additional streams for system-level isolation (per-product, per-tenant)
ALWAYS specify MessageStream on every send.
NEVER send broadcast content via the outbound stream.
## Server token scoping
Postmark uses per-server tokens, NOT per-account tokens:
- Generate one server per environment (Dev / Staging / Prod)
- Generate one server per major product/tenant if isolation is needed
- A leaked token compromises ONE server, not the whole account
NEVER use a single POSTMARK_SERVER_TOKEN across dev, staging, prod.
NEVER commit any server token to source control.
## Hard rules
- NEVER hardcode POSTMARK_SERVER_TOKEN in source files
- NEVER omit MessageStream on a send call
- NEVER send via the outbound (transactional) stream for marketing content
- NEVER use plain string interpolation in template variables, ALWAYS use TemplateModel
- NEVER ignore bounce webhooks, suppression list growth is silent reputation damage
- NEVER retry on a 422 (bad request) or 401 (bad token), only on 5xx
- ALWAYS use sendEmailWithTemplate for any send with merge fields
- ALWAYS verify webhook signature via the X-Postmark-Signature header
The message stream rule is the policy that protects deliverability. Postmark internally tracks engagement metrics per stream. Sending a low-engagement broadcast through your transactional stream poisons the deliverability of your password resets and order receipts. The platform will warn you, and if the engagement keeps degrading, it will suspend the stream. The fix is architectural: keep the streams separate from day one.
The per-server token rule limits the blast radius of any leaked credential. Postmark generates a separate token per server (per environment, per tenant), so a leak in your CI logs compromises only that one server's send capability. Reusing one token across environments multiplies the impact of every leak.
Install and client setup
npm i postmark
Add the token to your environment file:
# .env.local
POSTMARK_SERVER_TOKEN=your-dev-server-token
POSTMARK_WEBHOOK_SECRET=your-webhook-secret-from-dashboard
Create the singleton client:
// src/lib/postmark.ts
import { ServerClient } from 'postmark';
if (!process.env.POSTMARK_SERVER_TOKEN) {
throw new Error('POSTMARK_SERVER_TOKEN is not defined');
}
export const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN);
The startup check makes a missing token fail immediately at boot rather than on the first send call. Claude omits this check by default. Add the singleton pattern to CLAUDE.md so Claude does not instantiate a new client inline in every route handler.
For projects with separate transactional and broadcast streams, you can either keep one client and pass the stream on each call (the recommended pattern) or maintain two clients, one per server token if the streams live on different Postmark servers. The one-client approach is simpler and works for most cases.
The send pattern with templates
Postmark templates are managed in the dashboard. Each template has a unique ID (a numeric ID like 12345678 or an alias like welcome-email). The template ID is used in the API call, and merge fields are passed as a TemplateModel object.
// src/lib/email-templates.ts
export const TEMPLATES = {
welcome: 'welcome-email',
passwordReset: 'password-reset',
orderConfirmation: 'order-confirmation',
invoicePaid: 'invoice-paid',
} as const;
export interface WelcomeModel {
user_name: string;
login_url: string;
product_name: string;
}
export interface PasswordResetModel {
user_name: string;
reset_url: string;
expires_in_hours: number;
}
export interface OrderConfirmationModel {
user_name: string;
order_id: string;
order_total: string;
order_items: Array<{ name: string; quantity: number; price: string }>;
}
The send wrapper around sendEmailWithTemplate:
// src/lib/send-email.ts
import { postmark } from '@/lib/postmark';
import type { TemplatedMessage } from 'postmark';
const FROM_ADDRESS = 'hello@yourdomain.com';
interface SendTemplatedEmailOptions<T> {
to: string;
templateAlias: string;
templateModel: T;
messageStream?: string;
tag?: string;
replyTo?: string;
}
export async function sendTemplatedEmail<T extends Record<string, unknown>>(
options: SendTemplatedEmailOptions<T>,
) {
const message: TemplatedMessage = {
From: FROM_ADDRESS,
To: options.to,
TemplateAlias: options.templateAlias,
TemplateModel: options.templateModel,
MessageStream: options.messageStream ?? 'outbound',
Tag: options.tag,
ReplyTo: options.replyTo,
TrackOpens: true,
TrackLinks: 'HtmlAndText',
};
try {
const result = await postmark.sendEmailWithTemplate(message);
if (result.ErrorCode !== 0) {
console.error('[Postmark] Send error:', result.ErrorCode, result.Message);
throw new Error(`Postmark send failed: ${result.Message}`);
}
return result;
} catch (e) {
console.error('[Postmark] Exception:', e);
throw e;
}
}
Three details matter here.
The MessageStream: options.messageStream ?? 'outbound' line makes the stream explicit on every send. Without the default, the SDK uses the server's default stream, which is fine when you only have one stream but breaks when you add a second. Making the parameter explicit means future-you can swap the default to 'broadcast' for a new product without rewriting every send call site.
The ErrorCode !== 0 check is the Postmark-specific way to detect failures. Unlike most APIs, Postmark returns a 200 OK HTTP status with an ErrorCode field in the body when the request was processed but the message could not be sent (invalid recipient, suppressed address, template not found). Claude defaults to checking the HTTP status only, which means these errors slip through silently.
The TrackOpens: true and TrackLinks: 'HtmlAndText' flags enable Postmark's tracking pixel and link wrapping. Set them to false if you have a privacy-first policy or if your industry (healthcare, finance) prohibits tracking pixels. The defaults Claude picks vary by SDK version, so being explicit matters.
Calling the wrapper from a route handler:
// src/app/api/welcome/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendTemplatedEmail } from '@/lib/send-email';
import { TEMPLATES } from '@/lib/email-templates';
export async function POST(req: NextRequest) {
const { email, name } = await req.json();
await sendTemplatedEmail({
to: email,
templateAlias: TEMPLATES.welcome,
templateModel: {
user_name: name,
login_url: 'https://yourdomain.com/login',
product_name: 'Your Product',
},
tag: 'welcome',
});
return NextResponse.json({ ok: true });
}
The Tag field on the message is the dimension Postmark uses for analytics in the dashboard. Tagging every send with the template alias (or a more granular categorisation) lets you slice deliverability and engagement metrics by feature. Without a tag, all sends fall into the "untagged" bucket and you lose the ability to diagnose a deliverability dip on a specific email type.
Template variable syntax
Postmark templates use a Mustache-style syntax for variable interpolation:
<!-- Postmark template -->
<p>Hello {{user_name}},</p>
<p>Your password reset link expires in {{expires_in_hours}} hours.</p>
<p><a href="{{reset_url}}">Reset password</a></p>
{{#if order_items}}
<table>
{{#each order_items}}
<tr>
<td>{{name}}</td>
<td>{{quantity}}</td>
<td>{{price}}</td>
</tr>
{{/each}}
</table>
{{/if}}
The TypeScript type for the template model matches the variables one-for-one:
interface PasswordResetModel {
user_name: string;
reset_url: string;
expires_in_hours: number;
}
Add a template syntax section to CLAUDE.md:
## Template variable syntax
- Variables: {{variable_name}} (double curly braces, snake_case)
- Conditionals: {{#if variable}} ... {{/if}}
- Loops: {{#each items}} ... {{/each}} with item fields as {{name}}
- ALWAYS define a TypeScript interface for the TemplateModel
- TypeScript interface field names MUST match template variable names exactly
- NEVER inject HTML into a template variable, Postmark escapes by default
- NEVER use {{{ triple braces }}} unless intentional unescaped insertion
The triple-brace rule matters in production. Postmark templates escape HTML by default with double braces. Triple braces ({{{variable}}}) emit the variable unescaped, which is required for fields that contain trusted HTML (a pre-rendered notification body, a help article excerpt) but is an XSS vector for any field that contains user-provided text. Add a CLAUDE.md note that explains when each form is appropriate.
Message streams in detail
Postmark requires you to create message streams explicitly in the dashboard before you can send to them. The default server has one stream named outbound for transactional. Adding a broadcast stream is a one-time setup step:
- In the Postmark dashboard, go to your server
- Click "Streams"
- Click "New stream"
- Choose stream type: Broadcast
- Give it an ID like
broadcast(the API ID, not the display name)
Once created, sends specify the stream:
// Transactional (default)
await sendTemplatedEmail({
to: 'user@example.com',
templateAlias: TEMPLATES.passwordReset,
templateModel: { /* ... */ },
messageStream: 'outbound',
});
// Broadcast
await sendTemplatedEmail({
to: 'user@example.com',
templateAlias: 'product-launch',
templateModel: { /* ... */ },
messageStream: 'broadcast',
});
Stream-specific concerns:
| Aspect | Transactional (outbound) | Broadcast |
|---|---|---|
| Use case | Receipts, alerts, password resets | Announcements, newsletters, promotions |
| Recipient expectation | Always wants this email | Has opted in but may not always engage |
| Suppression list | Shared with broadcast for hard bounces | Has its own unsubscribe list |
| Throttling | High send rate allowed | Throttled to protect reputation |
| Reputation impact | Affects domain reputation more | Isolated to broadcast stream metrics |
Add a streams section to CLAUDE.md:
## Message streams
- Transactional: outbound (default), use for receipts, alerts, password resets
- Broadcast: broadcast (manually created), use for newsletters, announcements
- Tag every send so dashboard metrics are sliceable
- NEVER send broadcast content through outbound, it degrades transactional deliverability
- NEVER send transactional content through broadcast, it may be throttled
For Stripe webhook receipts, Claude Code with Stripe covers the handler pattern that triggers Postmark sends from invoice.paid and checkout.session.completed events.
Bounce and complaint webhooks
Postmark fires webhooks for every email event: delivery, bounce, spam complaint, link click, open, subscription change. The two events that matter for deliverability are bounces and complaints. Suppression list growth is silent: nothing in the API tells you that your bounce rate is climbing toward the threshold where Postmark suspends the stream.
The webhook handler with signature verification:
// src/app/api/webhooks/postmark/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
const WEBHOOK_SECRET = process.env.POSTMARK_WEBHOOK_SECRET;
interface PostmarkBounceEvent {
RecordType: 'Bounce';
Type: 'HardBounce' | 'SoftBounce' | 'Transient' | 'Unknown';
TypeCode: number;
Email: string;
MessageID: string;
BouncedAt: string;
Tag: string;
}
interface PostmarkSpamComplaintEvent {
RecordType: 'SpamComplaint';
Email: string;
MessageID: string;
Tag: string;
}
interface PostmarkDeliveryEvent {
RecordType: 'Delivery';
Recipient: string;
MessageID: string;
DeliveredAt: string;
Tag: string;
}
type PostmarkEvent = PostmarkBounceEvent | PostmarkSpamComplaintEvent | PostmarkDeliveryEvent;
export async function POST(req: NextRequest) {
if (!WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const body = await req.text();
const signature = req.headers.get('X-Postmark-Signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('base64');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event: PostmarkEvent = JSON.parse(body);
switch (event.RecordType) {
case 'Bounce':
if (event.Type === 'HardBounce') {
await markAddressUndeliverable(event.Email);
}
break;
case 'SpamComplaint':
await suppressAddress(event.Email);
await alertOpsTeam(`Spam complaint: ${event.Email} (${event.MessageID})`);
break;
case 'Delivery':
await markEmailDelivered(event.MessageID);
break;
}
return NextResponse.json({ ok: true });
}
async function markAddressUndeliverable(email: string) {
// Update your user record to stop further sends
}
async function suppressAddress(email: string) {
// Add to internal suppression list immediately
}
async function alertOpsTeam(message: string) {
// Slack, PagerDuty, or whatever alerting channel
}
async function markEmailDelivered(messageId: string) {
// Update DB row for the message
}
Three details matter and Claude misses them by default.
The crypto.timingSafeEqual comparison instead of === prevents timing attacks against the signature check. The string comparison a === b short-circuits on the first byte that differs, which an attacker can use to brute-force the signature one byte at a time. timingSafeEqual compares all bytes in constant time. The Buffer wrapping is required because timingSafeEqual operates on Buffers.
The hard-bounce-only treatment of bounces matters because soft bounces are temporary (mailbox full, server briefly unavailable) and should not result in permanent address suppression. Claude often suppresses on every bounce, which over time empties your sendable user list. The event.Type === 'HardBounce' check filters for permanent failures only.
The spam complaint handler should both suppress the address and alert the ops team. A spam complaint is a high-severity event because it signals that your content reached someone who did not want it, which damages domain reputation immediately. Logging it without alerting means the dashboard accumulates complaints until the deliverability degrades enough to be noticed.
Add a webhooks section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/postmark/route.ts
- ALWAYS verify X-Postmark-Signature using HMAC SHA256 + timingSafeEqual
- POSTMARK_WEBHOOK_SECRET in .env.local
- Bounce types: HardBounce, SoftBounce, Transient, Unknown
- HardBounce: permanent failure, suppress address
- SoftBounce: temporary, do NOT suppress
- Transient: retry-on-server-side, no action needed
- Unknown: log for review, no action
- SpamComplaint: suppress address immediately AND alert ops team
- Delivery: optional, useful for analytics dashboards
- Webhook setup: dashboard > Servers > [server] > Webhooks > Create new webhook
- Subscribe to: Bounce, SpamComplaint, Delivery (at minimum)
Suppression list management
Postmark maintains its own suppression list per server. Addresses that hard-bounce or complain are automatically added. The list survives across deploys, persists indefinitely, and applies to every send from that server.
You can read and modify the suppression list via the API:
// scripts/check-suppression.ts
import { postmark } from '@/lib/postmark';
async function checkSuppression(email: string) {
const result = await postmark.getSuppressions('outbound', { emailAddress: email });
return result.Suppressions.length > 0;
}
async function removeSuppression(email: string) {
await postmark.deleteSuppressions('outbound', {
Suppressions: [{ EmailAddress: email }],
});
}
The outbound parameter is the message stream ID. Suppressions are per-stream, so removing from outbound does not affect the broadcast suppression list.
When to remove a suppression: a user who hard-bounced once because their inbox was full but who then re-engaged with the service via a different channel, or a soft bounce that was incorrectly classified. Removing a suppression for a confirmed spam complainant is almost always wrong: the user marked your email as spam, and continuing to send to them damages reputation further.
Permission hooks for email scripts
A Postmark project accumulates scripts: seed scripts that send test emails, batch scripts that fire campaign sends, suppression managers, template renderers for local preview. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npx tsx scripts/preview-template.ts*)",
"Bash(npx tsx scripts/check-deliverability.ts*)",
"Bash(npx tsx scripts/list-suppressions.ts*)"
],
"deny": [
"Bash(npx tsx scripts/send-batch.ts*)",
"Bash(npx tsx scripts/clear-suppressions.ts*)",
"Bash(npx tsx scripts/send-broadcast.ts*)"
]
}
}
Previewing templates and listing suppressions are safe operations. Clearing the suppression list, sending a batch, or firing a broadcast send require explicit confirmation. For more on the permission model, Claude Code permissions covers the full configuration.
Common Claude Code mistakes with Postmark
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Inline ServerClient instantiation
Claude generates: const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN); at the top of every file.
Correct pattern: one singleton at src/lib/postmark.ts, imported everywhere.
2. Missing MessageStream
Claude generates: a send call without MessageStream, relying on the server default.
Correct pattern: every send call specifies MessageStream explicitly, even if it is 'outbound'.
3. HTTP-200-with-ErrorCode ignored
Claude generates: if (response.statusCode === 200) return ok(); checking only HTTP status.
Correct pattern: if (result.ErrorCode !== 0) throw new Error(result.Message); checking the Postmark-specific error field.
4. String interpolation in templates
Claude generates: a "template" that is just a string with ${user_name} interpolated into HTML.
Correct pattern: use the Postmark template UI, pass a TemplateModel object with typed fields.
5. Signature comparison with ===
Claude generates: if (signature === expected) in the webhook handler.
Correct pattern: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)).
6. Hard-suppress on every bounce
Claude generates: webhook handler that adds the address to the suppression list on any bounce event.
Correct pattern: only suppress on Type === 'HardBounce' and on SpamComplaint, leave soft bounces alone.
Add these six pairs to CLAUDE.md as before/after examples. Claude reproduces concrete patterns faster than abstract rules.
When to choose Postmark vs an alternative
Postmark is the right choice when deliverability is the top priority, when you can accept the transactional-only constraint, and when you want a provider that aggressively protects sender reputation through technical and policy enforcement. The trade-off is less flexibility: no marketing-blast features, stricter content rules, faster account reviews if engagement metrics degrade.
If you need a single provider for both transactional and broadcast with more relaxed enforcement, Claude Code with Resend covers Resend, which has a more permissive design and a developer-first DX. If you need the absolute lowest cost per email at high volume and are willing to manage deliverability yourself, AWS SES is the option to consider. Postmark sits in the middle: higher price than SES, lower than full marketing platforms, and the deliverability is the value.
The CLAUDE.md template in this guide produces Postmark integrations where streams are separated, templates use the correct Mustache syntax with typed models, webhooks verify signatures with constant-time comparison, and bounce handling distinguishes hard from soft. The underlying principle: Postmark without explicit CLAUDE.md constraints produces code that violates the platform's reputation model and triggers escalating warnings, and the template removes each failure mode by making the correct pattern the only pattern Claude can generate.
Get Claudify. The bundle includes a Postmark CLAUDE.md template with the singleton client, message stream rules, template variable patterns, webhook verification, and all six common-mistake rules pre-configured.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify