Guide · Transactional Email
MCP Tools for Postmark — X-Postmark-Server-Token auth, message streams, bounce classification, and suppression
Postmark is a transactional email API optimized for high deliverability, commonly used in MCP tools for sending password resets, notifications, and reports. Three constraints distinguish it from other email APIs: authentication uses a custom X-Postmark-Server-Token header — not Bearer tokens, not Basic auth, not API key query params; every server has separate message streams for transactional and broadcast email, each with its own token and suppression list; and bounce handling exposes a two-tier classification system (HardBounce vs SoftBounce) that determines whether to permanently suppress or retry the address. Postmark's API responses are consistently JSON and always include a numeric ErrorCode field — 0 means success even when the HTTP status is 200.
TL;DR
Auth: X-Postmark-Server-Token: {YOUR_SERVER_TOKEN} header. Base URL: https://api.postmarkapp.com. Send: POST /email with JSON body containing From, To, Subject, TextBody, HtmlBody, and MessageStream (use "outbound" for transactional, or your broadcast stream ID). Batch: POST /email/batch (up to 500 messages). Bounce list: GET /bounces. Suppression list: GET /message-streams/{stream}/suppressions. Template: POST /email/withTemplate with TemplateAlias and TemplateModel. Check ErrorCode === 0 in every response body, not just HTTP status.
Authentication — X-Postmark-Server-Token header
Postmark uses a proprietary authentication header, not standard HTTP auth. The header name is X-Postmark-Server-Token and the value is your server's API token (visible in the Postmark dashboard under the server settings). Every Postmark server has its own token — tokens are not account-level or domain-level.
Postmark also has an Account API token (X-Postmark-Account-Token header), used for managing servers, domains, and templates via the Account API. Never use the account token in MCP tool code that sends email — use the server token, which has limited scope if compromised.
// Postmark client
class PostmarkClient {
constructor(serverToken) {
this.serverToken = serverToken;
this.baseUrl = 'https://api.postmarkapp.com';
}
async request(method, path, body = null) {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
// Custom auth header — NOT "Authorization: Bearer ..."
'X-Postmark-Server-Token': this.serverToken,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : null,
signal: AbortSignal.timeout(30_000),
});
const data = await res.json();
// Postmark ALWAYS returns JSON with an ErrorCode field
// ErrorCode 0 = success; any other value = failure
// HTTP status is also reliable but ErrorCode is the canonical signal
if (!res.ok || data.ErrorCode !== 0) {
throw new Error(
`Postmark ${method} ${path} → HTTP ${res.status}, ErrorCode ${data.ErrorCode}: ${data.Message}`
);
}
return data;
}
}
// Send a transactional email
async function sendTransactional(client, { from, to, subject, textBody, htmlBody, tag }) {
return client.request('POST', '/email', {
From: from,
To: Array.isArray(to) ? to.join(',') : to,
Subject: subject,
TextBody: textBody,
HtmlBody: htmlBody,
// Always specify MessageStream — omitting defaults to "outbound" but being
// explicit avoids confusion when the server has multiple streams
MessageStream: 'outbound',
// Tag groups messages in Postmark dashboard statistics
Tag: tag,
// TrackOpens and TrackLinks require Postmark plan support
TrackOpens: true,
});
}
Message streams — transactional vs broadcast
Postmark servers contain named message streams, each with its own sending path, suppression list, and deliverability reputation. Every new server has a default outbound (transactional) stream. Broadcast streams must be explicitly created and are subject to stricter anti-spam review before activation.
The critical operational difference: Postmark's transactional stream bypasses the unsubscribe suppression list. Marketing/broadcast sends check the broadcast stream's suppression list before each delivery. Never send marketing email through the transactional stream — it bypasses unsubscribe records and violates CAN-SPAM/GDPR.
// List all message streams for a server
async function listStreams(client) {
const result = await client.request('GET', '/message-streams');
return result.MessageStreams.map(s => ({
id: s.ID,
name: s.Name,
type: s.MessageStreamType, // 'Transactional' | 'Broadcasts'
description: s.Description,
createdAt: s.CreatedAt,
archivedAt: s.ArchivedAt, // null if active
}));
}
// Send a broadcast email (newsletter, marketing)
async function sendBroadcast(client, { from, to, subject, htmlBody, streamId }) {
return client.request('POST', '/email', {
From: from,
To: to,
Subject: subject,
HtmlBody: htmlBody,
// Use the broadcast stream ID — NOT 'outbound'
// Postmark checks the broadcast suppression list and unsubscribe link
MessageStream: streamId,
// Broadcast emails MUST include an unsubscribe mechanism
// If using Postmark's built-in: include {{#}}Unsubscribe{{/}} in HTML
});
}
Bounce handling and suppression
Postmark exposes a unified bounce API at GET /bounces with a rich classification. The Type field determines the correct action: HardBounce means the address is permanently invalid (user does not exist, domain does not exist) — suppress it permanently and never retry. SoftBounce is temporary (mailbox full, server temporarily unavailable) — retry is appropriate after a delay. SpamComplaint must trigger immediate suppression across all streams.
// Fetch and classify recent bounces
async function getBounces(client, { count = 50, type = null } = {}) {
const params = new URLSearchParams({ count: String(count) });
if (type) params.set('type', type);
const result = await client.request('GET', `/bounces?${params}`);
return result.Bounces.map(b => ({
id: b.ID,
type: b.Type, // 'HardBounce', 'SoftBounce', 'SpamComplaint', etc.
email: b.Email,
bouncedAt: b.BouncedAt,
subject: b.Subject,
description: b.Description,
details: b.Details, // SMTP diagnostic from receiving server
canActivate: b.CanActivate, // true if reactivation API will work
messageStream: b.MessageStream,
action: b.Type === 'HardBounce' || b.Type === 'SpamComplaint'
? 'suppress-permanently'
: 'retry-with-backoff',
}));
}
// Reactivate a bounced address (only valid when CanActivate=true)
// Use this when the bounce was incorrect (e.g., user updated their MX records)
async function reactivateBounce(client, bounceId) {
return client.request('PUT', `/bounces/${bounceId}/activate`);
}
// Per-stream suppression management (Postmark v2 suppressions API)
async function getSuppressions(client, streamId) {
const result = await client.request(
'GET',
`/message-streams/${streamId}/suppressions`
);
return result.Suppressions.map(s => ({
email: s.EmailAddress,
suppressionReason: s.SuppressionReason, // 'HardBounce', 'SpamComplaint', 'ManualSuppression'
origin: s.Origin, // 'Recipient', 'Customer', 'Admin'
createdAt: s.CreatedAt,
}));
}
// Delete suppression (re-enable delivery to an address)
async function deleteSuppression(client, streamId, email) {
return client.request('POST', `/message-streams/${streamId}/suppressions/delete`, {
Suppressions: [{ EmailAddress: email }],
});
}
Template sends and variable substitution
Postmark's template engine uses Mustache syntax. Variables in the template are wrapped in {{variable}} (HTML-escaped) or {{{variable}}} (unescaped HTML). Templates are addressed by alias string, not numeric ID — use aliases because IDs change if a template is deleted and recreated, while aliases are controlled by you.
async function sendWithTemplate(client, { to, templateAlias, model, stream = 'outbound' }) {
return client.request('POST', '/email/withTemplate', {
To: to,
// Address templates by alias (stable) not TemplateId (changes on recreate)
TemplateAlias: templateAlias,
// TemplateModel is the object injected into Mustache {{variable}} placeholders
TemplateModel: model,
MessageStream: stream,
// From is set on the template in Postmark dashboard, but can override here
// From: 'sender@yourdomain.com',
});
}
// Batch template send (up to 500, must all use same template)
async function batchWithTemplate(client, messages) {
return client.request('POST', '/email/batchWithTemplates', {
Messages: messages.map(m => ({
To: m.to,
TemplateAlias: m.templateAlias,
TemplateModel: m.model,
MessageStream: m.stream || 'outbound',
})),
});
}
Health monitoring for Postmark MCP tools
Postmark does not expose a dedicated health endpoint. A reliable probe should: verify the server token is valid, confirm the sending stream is active, and check for elevated bounce rates that might indicate deliverability problems.
async function probePostmark(client, streamId = 'outbound') {
const results = await Promise.allSettled([
// 1. Token validity — list streams, very lightweight
(async () => {
const streams = await listStreams(client);
const targetStream = streams.find(s => s.id === streamId);
if (!targetStream) throw new Error(`Stream '${streamId}' not found on this server`);
if (targetStream.archivedAt) throw new Error(`Stream '${streamId}' is archived`);
return { kind: 'auth', activeStreams: streams.filter(s => !s.archivedAt).length };
})(),
// 2. Recent delivery stats — confirms pipeline is not stalled
(async () => {
// GET /stats/outbound?tag=... — returns aggregate delivery stats
const result = await client.request('GET', `/stats/outbound?messagestream=${streamId}`);
const sent = result.Sent ?? 0;
const bounced = result.Bounced ?? 0;
const bounceRate = sent > 0 ? (bounced / sent * 100).toFixed(2) : '0.00';
const highBounce = sent > 10 && bounced / sent > 0.05; // >5% is a red flag
if (highBounce) {
throw new Error(`High bounce rate: ${bounceRate}% (${bounced}/${sent})`);
}
return { kind: 'stats', sent, bounced, bounceRate: parseFloat(bounceRate) };
})(),
// 3. Suppression list size — rapid growth indicates a deliverability problem
(async () => {
const suppressions = await getSuppressions(client, streamId);
return { kind: 'suppressions', count: suppressions.length };
})(),
]);
return {
healthy: results.every(r => r.status === 'fulfilled'),
components: results.map(r =>
r.status === 'fulfilled'
? { ...r.value, ok: true }
: { ok: false, error: r.reason?.message }
),
};
}
Common integration pitfalls
- Using Bearer or Basic auth instead of the custom header
- Postmark uses
X-Postmark-Server-Token, notAuthorization: Bearerand not HTTP Basic. Sending the token in anAuthorizationheader results in a 401 with an error message that mentions the correct header name — but only if you read the response body, not just the status code. - Mixing transactional and broadcast streams
- Sending marketing content through the
outboundtransactional stream bypasses unsubscribe records and violates CAN-SPAM. Postmark may suspend transactional sending if they detect marketing content in the transactional stream. Create a separate broadcast stream for newsletters and bulk sends. - Treating ErrorCode non-zero as just a log event
- Postmark returns
ErrorCode: 300for "Invalid email address" andErrorCode: 406for "Inactive recipient" with HTTP 200. If you only check the HTTP status and ignoreErrorCode, both cases look like successful sends. Always assertErrorCode === 0. - Not reactivating recoverable bounces
- Some bounces are false positives (misconfigured receiving server, temporary DNS failure at time of send). If
CanActivate: true, the reactivation API endpoint will work. Permanently suppressing recoverable bounces reduces deliverable list size unnecessarily. - Sending to suppressions via the batch API
- Postmark's batch endpoint (
/email/batch) skips suppressed addresses silently — it does not return an error for suppressed recipients. The batch response includes per-message results; check eachErrorCodeindividually to detect silent skips.
Related guides
- MCP tools for Mailgun — Basic auth with api: prefix, regional endpoints, webhook HMAC, bounces
- MCP tools for AWS SES — IAM auth, sending quota, sandbox mode, SNS bounce notifications
- MCP tools for Resend — Bearer token auth, batch sends, Svix webhook validation
- MCP tools for SparkPost — transmission API, substitution data, engagement tracking
- MCP server health check patterns — composite endpoints and failure classification
- MCP server uptime monitoring — probes, intervals, and alert routing