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, not Authorization: Bearer and not HTTP Basic. Sending the token in an Authorization header 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 outbound transactional 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: 300 for "Invalid email address" and ErrorCode: 406 for "Inactive recipient" with HTTP 200. If you only check the HTTP status and ignore ErrorCode, both cases look like successful sends. Always assert ErrorCode === 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 each ErrorCode individually to detect silent skips.

Related guides