Transactional Email · 2026-07-16 · Email arc

MCP Tools for Transactional Email: Five Auth Shapes, Delivery Failure Notification Models, and Domain Verification Prerequisites

Five transactional email APIs — Mailgun, Postmark, AWS SES, Resend, and SparkPost — and each one uses a completely different credential encoding scheme. Not "slightly different" — zero overlap. Mailgun uses HTTP Basic auth where the username is the literal string api and the password is the API key, encoded as base64("api:{key}"). Postmark uses a proprietary header named X-Postmark-Server-Token — no Authorization header at all. AWS SES has no API key concept: authentication is IAM-based, with the AWS SDK handling SigV4 request signing using access keys or instance roles; you never construct an Authorization header manually. Resend uses standard OAuth2-style Bearer tokens with a mandatory re_ prefix on the key. SparkPost puts the raw API key in the Authorization header with no prefix — not Bearer {key}, not Basic {key}, just the bare key string. Sending Bearer {key} to SparkPost returns a 401 that looks identical to a wrong key, with no diagnostic message indicating which convention was violated. Beyond credential encoding, all five platforms diverge on two structural patterns that determine whether an MCP tool's delivery pipeline can be trusted: how they notify you when email is not delivered, and what domain verification prerequisites must be satisfied before any send is accepted. Mailgun and Postmark expose REST endpoints for pulling bounce lists; AWS SES delivers all bounce and complaint notifications exclusively through SNS — there is no equivalent REST endpoint in the SESv2 API; Resend uses Svix webhook events without bounce type classification; SparkPost offers a first-class suppression list API. On domain verification, the five providers share a requirement that sending domains must pass DNS verification, but AWS SES adds a second layer unique to the group: sandbox mode, which restricts sends to pre-verified recipient addresses until production access is requested via AWS Support. This post covers all three patterns — credential encoding, delivery failure notification, and domain prerequisites — with health probe designs that surface failures before users encounter them.

TL;DR

Five providers, three patterns. (1) Auth credential encoding: Mailgun — HTTP Basic with username api and API key as password (Authorization: Basic base64("api:{key}")); Postmark — proprietary X-Postmark-Server-Token: {token} header, no Authorization header; AWS SES — IAM SigV4 signing via @aws-sdk/client-sesv2 SDK, no HTTP auth header; Resend — standard Authorization: Bearer re_xxx; SparkPost — raw key in Authorization header with no prefix: Authorization: {key}. (2) Delivery failure notification model: Mailgun — REST bounce list at GET /{domain}/bounces + webhook events; Postmark — REST bounce list at GET /bounces with hard/soft classification + per-stream suppression API; AWS SES — asynchronous SNS push only, no bounce list REST endpoint in SESv2; Resend — Svix webhook events with no bounce type classification; SparkPost — webhook events + proactive suppression list API at GET /suppression-list/{email}. (3) Domain verification prerequisites: all five require DNS-verified sending domains; Mailgun requires domain state: "active" or sends are silently queued/rejected; AWS SES adds sandbox mode (all new accounts start here, can only send to verified addresses) — unique among the five, requires AWS Support request to exit; Resend has a four-stage state machine (not_started → pending → verified → failed); SparkPost checks status.ownership_verified and status.dkim_status: "valid". Monitor all five with AliveMCP.

Pattern 1: Auth Credential Encoding — Five Shapes With Zero Cross-Provider Conventions

Transactional email APIs are one of the few API categories where you cannot transfer any assumption from one provider to another. Every major email API uses a distinct authentication mechanism — different HTTP header names, different encoding schemes, different key formats, and different failure responses when the encoding is wrong. The practical consequence for MCP tool authors is that each provider integration must be implemented from scratch for auth, even if the send operation conceptually does the same thing.

Mailgun uses HTTP Basic authentication — the oldest form of HTTP auth — but with a non-obvious twist: the username is always the literal string api, not your Mailgun account email, not your domain name. The Basic auth value is base64("api:{your_api_key}"). Developers who know HTTP Basic typically assume the username is an account identifier. On Mailgun, the username is fixed to api for all accounts, and the key material is entirely in the password field. A 401 from Mailgun does not tell you which half of the credential was wrong — you get a JSON error with message "Forbidden" — so if you accidentally set the username to your email address (which is a completely reasonable assumption from the HTTP Basic spec), you get the same 401 as a wrong API key.

// Mailgun: username is the literal string "api", key is the password
// NOT: base64("{email}:{key}") — that's Zendesk
// NOT: base64("{key}:") — that's some other APIs
// CORRECT: base64("api:{key}")
const credentials = Buffer.from(`api:${process.env.MAILGUN_API_KEY}`).toString('base64');
const authHeader = `Basic ${credentials}`;

// Verify the encoding before making any other call:
// GET https://api.mailgun.net/v3/domains
// Returns 401 if the "api" prefix is missing or the key is wrong

Mailgun also differentiates between private API keys (full account access, starts with key- in older accounts) and sending API keys (domain-scoped, can only send via specific domains). Use sending API keys in production MCP tools — a leaked key can only send from designated domains, not access the full account.

Postmark does not use the HTTP Authorization header at all. Authentication is via a proprietary header named X-Postmark-Server-Token. The value is a server token string (a UUID-format string visible in the Postmark dashboard under server settings). No prefix, no encoding, just the token string as the header value. Sending a standard Authorization: Bearer {token} header to Postmark results in a 401 where the error message will tell you the name of the correct header — but only if you read the response body. Many generic HTTP clients only surface the status code, not the body, so the error disappears into a generic "unauthorized" log line.

// Postmark: no Authorization header — use X-Postmark-Server-Token
// NOT: Authorization: Bearer {token}
// NOT: Authorization: Basic base64(":{token}")
// CORRECT: X-Postmark-Server-Token: {token}

const res = await fetch('https://api.postmarkapp.com/email', {
  method: 'POST',
  headers: {
    'X-Postmark-Server-Token': process.env.POSTMARK_SERVER_TOKEN,
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({ From, To, Subject, TextBody, MessageStream: 'outbound' }),
});

// Postmark also has an account-level token (X-Postmark-Account-Token) for
// managing servers and domains — never use it in send-path MCP tool code

Postmark's response body always includes an ErrorCode field. ErrorCode: 0 means success. Any non-zero value is a failure — even when the HTTP status is 200. For example, ErrorCode: 300 with HTTP 200 means "invalid email address"; ErrorCode: 406 with HTTP 200 means "inactive recipient (suppressed)". If your error handling only checks res.ok without reading ErrorCode, these silent delivery failures will never surface in your logs.

AWS SES is the outlier among the five: there is no API key in the conventional sense. Authentication uses AWS IAM credentials — an accessKeyId and secretAccessKey (or an IAM role for compute resources) — and the AWS SDK handles SigV4 request signing. You never construct an Authorization header. There are no curl examples that show a simple header value you can copy into your MCP tool. The SDK is the auth layer.

import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';

// Option 1: explicit credentials (development / cross-account)
const ses = new SESv2Client({
  region: process.env.AWS_REGION || 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

// Option 2: implicit credentials from environment / IAM role (preferred in production)
// The SDK resolves: env vars → ~/.aws/credentials → EC2/ECS/Lambda metadata
const ses = new SESv2Client({ region: 'us-east-1' });

// CRITICAL: use @aws-sdk/client-sesv2 (v2 API), NOT @aws-sdk/client-ses (v1 API)
// They are separate packages with different command names and response shapes
// sesv1: SendEmailCommand takes { Source, Destination, Message: { Subject, Body } }
// sesv2: SendEmailCommand takes { FromEmailAddress, Destination, Content }
// Mixing packages causes TypeScript errors and runtime shape mismatches

The minimum IAM permissions for an MCP tool that only sends email are sesv2:SendEmail, sesv2:GetAccount (for quota checks), and sesv2:GetEmailIdentity (for domain verification checks). Never grant ses:* — the wildcard includes identity deletion, quota modification, and suppression list management, which could be destructive if a key is compromised.

Resend uses standard Bearer token authentication — the most conventional pattern among the five. The API key format is re_ followed by a random alphanumeric string. Pass it as Authorization: Bearer re_xxx. The re_ prefix makes Resend keys visually distinguishable in config files and logs, which also means you can validate the prefix at startup to catch misconfigurations before the first API call:

// Resend: standard Bearer auth with mandatory re_ prefix
// Validate at startup — catches wrong key type pasted into config
if (!apiKey?.startsWith('re_')) {
  throw new Error(`Invalid Resend API key format — expected re_... prefix, got: ${apiKey?.slice(0, 8)}...`);
}

const res = await fetch('https://api.resend.com/emails', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ from, to, subject, text, html }),
});
// Response: { id: "msg_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }
// Rate limit state exposed on every response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset

SparkPost is the most surprising of the five: the Authorization header contains only the raw API key — no prefix. Not Bearer {key}, not Basic {base64(key:)}, just {key}. This is technically valid HTTP (the Authorization header value can be any token that the server declares it will accept) but it violates every convention developers learn from other APIs. The practical failure mode: a developer familiar with every other email API writes Authorization: Bearer {sparkpost_key}, gets a 401, and spends time debugging API key permissions, region routing, and domain configuration before discovering the auth scheme itself was wrong. The 401 body reads {"errors":[{"message":"Forbidden","code":"1303"}]} — the same response as a correct API key with insufficient grants.

// SparkPost: raw API key in Authorization — NO "Bearer" prefix
// NOT: `Authorization: Bearer ${apiKey}` — this produces 401, looks like wrong key
// NOT: `Authorization: Basic ${base64(apiKey)}` — also 401
// CORRECT: `Authorization: ${apiKey}`

const res = await fetch('https://api.sparkpost.com/api/v1/transmissions', {
  method: 'POST',
  headers: {
    'Authorization': process.env.SPARKPOST_API_KEY,  // raw key, no prefix
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({ recipients, content }),
});

// SparkPost also has regional endpoints like Mailgun:
// US: https://api.sparkpost.com/api/v1
// EU: https://api.eu.sparkpost.com/api/v1
// Wrong region returns 401 — same as wrong key — also confusing to debug

The auth comparison in table form, for quick reference when implementing a new provider integration:

Provider Header name Value format Key prefix Wrong-auth response
Mailgun Authorization Basic base64("api:{key}") None (username is literal api) 401 {"message":"Forbidden"}
Postmark X-Postmark-Server-Token Raw token string None (token is UUID-format) 401 with header name in body
AWS SES (SDK-managed, SigV4) Not a header — SDK signs requests N/A — IAM access key ID SDK throws InvalidClientTokenId
Resend Authorization Bearer re_xxx re_ 401 {"name":"missing_api_key"}
SparkPost Authorization {key} — raw, no prefix None 401 {"errors":[{"code":"1303"}]}

Pattern 2: Delivery Failure Notification — Pull-Based REST vs SNS-Only Push vs Unclassified Webhooks

An MCP tool that sends email is only reliable if it can observe what happens after a send is accepted. The sending API accepting a message is not delivery — it is queue admission. The delivery failure notification model determines whether your tool can discover bounces, complaints, and suppressions through synchronous REST queries, asynchronous webhook events, or SNS subscriptions that require separate infrastructure. These models are not interchangeable — they require different integration architectures.

Mailgun and Postmark both offer REST endpoints for querying bounce lists. You can pull a list of bounced addresses at any time by calling an API endpoint — no webhooks, no message queues, no async infrastructure required. This is the most MCP-tool-friendly approach because the tool can check suppression state synchronously before sending, query recent bounces as part of a health probe, and implement exponential backoff logic without external state.

Mailgun exposes per-domain suppression lists — three distinct lists for bounces, complaints, and unsubscribes — all queryable via GET requests:

// Mailgun: pull-based suppression lists (per domain)
// Check before send — query the bounce list for the recipient address
async function isMailgunSuppressed(client, domain, email) {
  try {
    // 200 = address is on the bounce list (suppressed)
    await client.request('GET', `/domains/${domain}/bounces/${encodeURIComponent(email)}`);
    return true;
  } catch (err) {
    if (err.message.includes('404')) return false;
    throw err;
  }
}

// List all bounces (paginated by default)
async function getMailgunBounces(client, domain, limit = 100) {
  const result = await client.request('GET', `/domains/${domain}/bounces?limit=${limit}`);
  return result.items.map(b => ({
    address: b.address,
    code: b.code,      // SMTP code that caused the bounce
    error: b.error,    // diagnostic message
    createdAt: b.created_at,
  }));
}

// Spam complaints — separate list from bounces
async function getMailgunComplaints(client, domain) {
  const result = await client.request('GET', `/domains/${domain}/complaints`);
  return result.items.map(c => ({ address: c.address, createdAt: c.created_at }));
}

Postmark's bounce model adds hard/soft classification, which determines the correct response action. The Type field on each bounce record is the key signal:

// Postmark: bounce classification with actionable types
async function getPostmarkBounces(client, { count = 50, type = null } = {}) {
  const params = new URLSearchParams({ count: String(count) });
  if (type) params.set('type', type);  // e.g., 'HardBounce', 'SoftBounce', 'SpamComplaint'

  const result = await client.request('GET', `/bounces?${params}`);
  return result.Bounces.map(b => ({
    id: b.ID,
    type: b.Type,
    email: b.Email,
    bouncedAt: b.BouncedAt,
    canActivate: b.CanActivate,  // true if the bounce can be reactivated (false positives)
    // Action based on type:
    // HardBounce → suppress permanently
    // SoftBounce → retry after delay (temporary condition)
    // SpamComplaint → suppress immediately across all streams
    action: (b.Type === 'HardBounce' || b.Type === 'SpamComplaint')
      ? 'suppress-permanently'
      : 'retry-with-backoff',
  }));
}

// Postmark per-stream suppression list (separate from the bounce list)
// Transactional and broadcast streams each maintain their own suppression list
async function getPostmarkSuppressions(client, streamId) {
  const result = await client.request('GET', `/message-streams/${streamId}/suppressions`);
  return result.Suppressions;
}

AWS SES is the most infrastructure-intensive of the five for delivery failure handling. SESv2 has no REST endpoint for listing bounced addresses — this endpoint exists in SESv1 but not in the current SESv2 API. All bounce and complaint notifications from AWS SES are delivered asynchronously through SNS topic subscriptions. Your MCP tool needs either an SNS subscriber endpoint, an SQS queue subscribed to the SNS topic, or a Lambda triggered by SNS before you can observe any delivery failures. There is no synchronous alternative.

// AWS SES: no pull-based bounce API in SESv2
// Bounce/complaint notifications come through SNS topics you configure on the sending identity

// 1. Create an SNS topic and configure SES to publish to it:
//    - In SES Console: Email Identities → select identity → Notifications → Edit
//    - Or via SDK: SESv2Client → PutEmailIdentityFeedbackAttributesCommand
//    - Set BounceForwardingEnabled, ComplaintForwardingEnabled, DeliveryNotificationsEnabled

// 2. Parse the SNS notification payload in your HTTP endpoint:
function parseSesNotification(snsPayload) {
  const parsed = JSON.parse(snsPayload.Message);
  if (parsed.notificationType === 'Bounce') {
    return {
      type: 'bounce',
      bounceType: parsed.bounce.bounceType,    // 'Permanent' | 'Transient'
      bounceSubtype: parsed.bounce.bounceSubType,
      bouncedRecipients: parsed.bounce.bouncedRecipients.map(r => r.emailAddress),
      action: parsed.bounce.bounceType === 'Permanent' ? 'suppress-permanently' : 'retry',
    };
  }
  if (parsed.notificationType === 'Complaint') {
    return {
      type: 'complaint',
      complainedRecipients: parsed.complaint.complainedRecipients.map(r => r.emailAddress),
      action: 'suppress-permanently',  // complaints always suppress immediately
    };
  }
  return { type: parsed.notificationType };
}

// 3. Account-level suppression list IS queryable (unlike per-message bounce history)
//    Addresses on this list are suppressed across all sends from the account
import { ListSuppressedDestinationsCommand } from '@aws-sdk/client-sesv2';
async function getSesAccountSuppressions(sesClient) {
  const result = await sesClient.send(new ListSuppressedDestinationsCommand({ PageSize: 100 }));
  return result.SuppressedDestinationSummaries.map(s => ({
    email: s.EmailAddress,
    reason: s.Reason,  // 'BOUNCE' | 'COMPLAINT'
    lastUpdateTime: s.LastUpdateTime,
  }));
}

Resend delivers bounce and complaint events through Svix-signed webhook payloads. The Svix infrastructure provides replay protection, key rotation support, and a standardized three-header signature scheme. The limitation: Resend webhook events do not include a bounce type classification field — there is no type: "hard" | "soft" field on a bounce event. You receive the event and recipient address but not the SMTP diagnostic that would distinguish a permanent address failure from a temporary mailbox-full condition.

// Resend: Svix webhook validation (three-header scheme, not a single HMAC)
import { createHmac } from 'crypto';

function validateResendWebhook(webhookSecret, headers, rawBody) {
  const svixId = headers['svix-id'];
  const svixTimestamp = headers['svix-timestamp'];
  const svixSignature = headers['svix-signature'];

  if (!svixId || !svixTimestamp || !svixSignature) {
    throw new Error('Missing Svix webhook headers');
  }

  // Replay protection: reject events older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - parseInt(svixTimestamp, 10));
  if (age > 300) throw new Error('Svix webhook timestamp too old');

  // Svix signing payload: "{svix-id}.{svix-timestamp}.{raw_body}"
  const toSign = `${svixId}.${svixTimestamp}.${rawBody}`;

  // Decode the signing key: strip "whsec_" prefix, then base64-decode
  const secretKey = Buffer.from(webhookSecret.replace(/^whsec_/, ''), 'base64');
  const computed = createHmac('sha256', secretKey).update(toSign).digest('base64');

  // Multiple signatures in svix-signature for key rotation (space-separated "v1,{sig}")
  const validSignatures = svixSignature.split(' ').map(s => s.split(',')[1]);
  const isValid = validSignatures.some(sig => sig === computed);
  if (!isValid) throw new Error('Svix webhook signature mismatch');

  return true;
}

// Resend webhook event types: email.sent, email.delivered, email.bounced,
// email.complained, email.clicked, email.opened
// email.bounced has no bounceType field — you must classify by SMTP code if available

SparkPost occupies the middle ground: it delivers delivery events through webhooks (like Resend) but also exposes a first-class suppression list REST API that you can query, add to, and delete from proactively. This lets MCP tools pre-filter recipient lists before a transmission — checking the suppression list before each send eliminates the rejected-recipient count without having to parse after-the-fact webhook events.

// SparkPost: proactive suppression list management (unique among the five)
async function isSparkPostSuppressed(client, email) {
  try {
    // 200 = email IS on suppression list (suppressed)
    // 404 = email is NOT on suppression list (safe to send)
    await client.request('GET', `/suppression-list/${encodeURIComponent(email)}`);
    return true;
  } catch (err) {
    if (err.message.includes('404')) return false;
    throw err;
  }
}

// Add an address to the suppression list (e.g., after a bounce webhook event)
async function addSparkPostSuppression(client, email, type = 'non_transactional') {
  await client.request('PUT', `/suppression-list/${encodeURIComponent(email)}`, {
    recipients: [{
      address: { email },
      type,  // 'non_transactional' | 'transactional' — different scopes
      description: 'Added by MCP tool after bounce event',
    }],
  });
}

// SparkPost transmission response shows rejected count synchronously
// Check both fields — HTTP 200 with total_rejected_recipients > 0 = partial failure
async function sendAndCheckRejections(client, transmission) {
  const result = await client.request('POST', '/transmissions', transmission);
  if (result.total_rejected_recipients > 0) {
    // Some addresses were on the suppression list — log and alert
    console.warn(`SparkPost: ${result.total_rejected_recipients} rejected recipients`);
  }
  return result;
}

The delivery failure notification model comparison — this determines what infrastructure your MCP tool must deploy:

Provider Pull-based bounce list Webhook events Bounce classification Pre-send suppression check
Mailgun Yes — GET /{domain}/bounces Yes (HMAC-SHA256) Implied by list type (bounces vs complaints) Yes — query bounce list by address
Postmark Yes — GET /bounces Yes Yes — HardBounce vs SoftBounce vs SpamComplaint Yes — GET /message-streams/{id}/suppressions
AWS SES No — SESv2 has no bounce list REST endpoint Yes (via SNS topic subscription) Yes — Permanent vs Transient in SNS payload Yes — account suppression list API
Resend No Yes (Svix three-header scheme) No bounce type field in events No dedicated suppression query
SparkPost Yes — suppression list API Yes Via webhook events Yes — query suppression list before send

Pattern 3: Domain Verification Prerequisites — DNS State Machines and the AWS Sandbox Trap

All five transactional email providers require sending domains to pass DNS verification before email is accepted. This is not a one-time setup step — it is an ongoing operational state that can regress. A DNS misconfiguration, domain expiry, or provider-side re-check failure can transition a domain out of verified state and silently block all sends. The failure mode when this happens is provider-specific: some providers reject the send with a clear error, others queue the message and never deliver it, and AWS SES compounds this with a sandbox mode that restricts recipient addresses until production access is separately approved.

Mailgun requires each sending domain to reach state: "active" before sends are accepted. The domain starts in unverified state when first added and transitions to active after the required DNS records (SPF TXT record, DKIM TXT record, MX records for receiving) are verified by Mailgun's DNS checker. DNS propagation can take up to 48 hours. During this window, Mailgun silently queues or drops sends from the unverified domain — the send API call returns HTTP 200 with "Queued. Thank you." as if the message was accepted, but it never delivers. A health probe that only validates auth misses this entirely.

// Mailgun: check domain verification state before any send
async function checkMailgunDomainState(client, domain) {
  const result = await client.request('GET', `/domains/${domain}`);
  const d = result.domain;

  if (d.state !== 'active') {
    const unverifiedRecords = [
      ...d.sending_dns_records,
      ...d.receiving_dns_records,
    ].filter(r => r.valid !== 'valid');

    throw new Error(
      `Mailgun domain "${domain}" is not active (state: ${d.state}). ` +
      `Unverified DNS records: ${unverifiedRecords.map(r => `${r.record_type} ${r.name}`).join(', ')}`
    );
  }

  return { domain: d.name, state: d.state };
}

Postmark does not expose a separate domain verification endpoint in the same way — instead, DKIM status and SPF alignment are surfaced in sending statistics and bounce rates over time. Postmark recommends verifying DKIM and SPF through their dashboard before enabling a sending domain; the consequences of missing DNS records are deliverability degradation (mail goes to spam) rather than API-level rejection. Each Postmark server is pre-approved for sending — domain verification affects deliverability, not API acceptance.

AWS SES has the most layered verification model of the five. Sending domains (email identities) must be verified via DKIM DNS records, which SES generates and provides for your domain's DNS configuration. But uniquely among the five providers, AWS SES also maintains a sandbox mode that restricts recipient addresses until the account is approved for production sending. In sandbox mode, SES only accepts sends where both the sending address and every recipient address are verified in your SES account. Sending to an unverified recipient in sandbox mode returns a hard error immediately — there is no silent queue behavior. The practical implication: a new AWS account will appear to function for test sends (verified addresses work) but silently fail when the same MCP tool is used in production against real user email addresses. Sandbox exit requires a manual request to AWS Support, which typically takes 24–72 hours to approve.

// AWS SES: check both account mode AND sending identity DKIM status
import { GetAccountCommand, GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';

async function checkSesSendingReadiness(sesClient, sendingDomain) {
  const [account, identity] = await Promise.all([
    sesClient.send(new GetAccountCommand({})),
    sesClient.send(new GetEmailIdentityCommand({ EmailIdentity: sendingDomain })),
  ]);

  const sandboxMode = !account.ProductionAccessEnabled;
  const dkimStatus = identity.DkimAttributes?.Status;  // 'SUCCESS' | 'PENDING' | 'FAILED' | 'TEMPORARY_FAILURE'
  const quota = account.SendQuota;
  const quotaRemaining = quota.Max24HourSend - quota.SentLast24Hours;
  const quotaUsedPct = (quota.SentLast24Hours / quota.Max24HourSend) * 100;

  const issues = [];
  if (sandboxMode) {
    issues.push('SANDBOX MODE: sends restricted to verified recipient addresses — request production access via AWS Support');
  }
  if (dkimStatus !== 'SUCCESS') {
    issues.push(`DKIM status for ${sendingDomain}: ${dkimStatus} (sends may be rejected or go to spam)`);
  }
  if (quotaUsedPct > 80) {
    issues.push(`Quota at ${quotaUsedPct.toFixed(1)}% — ${quotaRemaining} sends remaining today`);
  }

  return { sandboxMode, dkimStatus, quotaUsedPct, issues };
}

Resend exposes a four-state verification state machine per domain: not_started (domain added but verification not initiated), pending (DNS records added, waiting for Resend to verify), verified (fully operational), and failed (verification attempt failed — typically due to incorrect DNS values). Sends from a non-verified domain are rejected with a 422 error. Unlike Mailgun's silent queue behavior, Resend surfaces this as an API error immediately, which makes it easier to detect but also means your MCP tool will log an error on every attempted send during the DNS propagation window.

// Resend: four-stage domain verification state machine
async function checkResendDomainVerification(client, domainId) {
  const { data } = await client.request('GET', `/domains/${domainId}`);

  // state: 'not_started' | 'pending' | 'verified' | 'failed'
  if (data.status !== 'verified') {
    // Trigger a re-check if DNS records may have propagated since last check
    if (data.status === 'pending') {
      await client.request('POST', `/domains/${domainId}/verify`);
    }

    throw new Error(
      `Resend domain not verified (status: ${data.status}). ` +
      `Add DNS records: ${JSON.stringify(data.records)}`
    );
  }

  return { domain: data.name, status: data.status, region: data.region };
}

SparkPost checks two independent conditions for a sending domain to be fully operational: status.ownership_verified (TXT record proving domain ownership, a one-time check) and status.dkim_status: "valid" (DKIM signing operational, ongoing). Both must be true. A domain can have ownership verified but DKIM not yet configured — it will pass ownership checks but fail deliverability. SparkPost also operates on US and EU regional infrastructure, and domain verification is region-specific: a domain verified in the US SparkPost account is not automatically verified in the EU account.

// SparkPost: check both ownership_verified AND DKIM status
async function checkSparkPostDomain(client, domain) {
  const result = await client.request('GET', `/sending-domains/${domain}`);
  const status = result.status;

  const issues = [];
  if (!status.ownership_verified) {
    issues.push('Ownership TXT record not verified — add the TXT record from SparkPost dashboard');
  }
  if (status.dkim_status !== 'valid') {
    issues.push(`DKIM status: ${status.dkim_status} — check DKIM TXT record propagation`);
  }

  if (issues.length > 0) {
    throw new Error(`SparkPost sending domain "${domain}" not fully verified: ${issues.join('; ')}`);
  }

  return {
    domain: result.domain,
    ownershipVerified: status.ownership_verified,
    dkimStatus: status.dkim_status,
    complianceStatus: status.compliance_status,  // 'valid' | 'pending'
  };
}

Composite Health Probe Design for All Five Providers

A health probe for a transactional email MCP tool must verify all three layers: credential validity, domain verification state, and sending quota/sandbox status. A probe that only pings an auth endpoint can show green while the domain is in unverified state (Mailgun silently queues), sandbox mode is active (AWS SES), or DKIM is broken (SparkPost). The composite probe below covers all three layers for each provider:

// Composite health probe — covers auth + domain + quota for all 5 providers

// Mailgun
async function healthMailgun(client, domain) {
  // Auth check: GET /domains returns 401 if key is wrong
  const domains = await client.request('GET', '/domains');
  // Domain check: must be state="active"
  const domainInfo = await client.request('GET', `/domains/${domain}`);
  const domainHealth = domainInfo.domain.state;
  // Bounce rate check: spam complaint rate
  const stats = await client.request('GET', `/domains/${domain}/stats/total?event=complained&duration=7d`);
  return {
    auth: 'ok',
    domain: domainHealth === 'active' ? 'active' : `degraded:${domainHealth}`,
    complaintRate: stats,
  };
}

// Postmark
async function healthPostmark(client) {
  // Auth + server check
  const streams = await client.request('GET', '/message-streams');
  // Outbound stream bounce rate
  const stats = await client.request('GET', '/stats/outbound?fromdate=2026-01-01');
  return {
    auth: 'ok',
    streams: streams.MessageStreams.map(s => ({ id: s.ID, type: s.MessageStreamType })),
    bounceRate: stats.BounceRate,
  };
}

// AWS SES
async function healthSes(sesClient, sendingDomain) {
  const [account, identity] = await Promise.all([
    sesClient.send(new GetAccountCommand({})),
    sesClient.send(new GetEmailIdentityCommand({ EmailIdentity: sendingDomain })),
  ]);
  return {
    auth: 'ok',  // SDK call succeeded = credentials valid
    sandboxMode: !account.ProductionAccessEnabled,
    dkimStatus: identity.DkimAttributes?.Status,
    sendQuotaUsedPct: (account.SendQuota.SentLast24Hours / account.SendQuota.Max24HourSend) * 100,
  };
}

// Resend
async function healthResend(client) {
  const { data: domains } = await client.request('GET', '/domains');
  return {
    auth: 'ok',
    domains: domains.data.map(d => ({ id: d.id, name: d.name, status: d.status })),
  };
}

// SparkPost
async function healthSparkPost(client) {
  // Account-level health
  const account = await client.request('GET', '/account');
  // Sending domains
  const sendingDomains = await client.request('GET', '/sending-domains');
  return {
    auth: 'ok',
    accountStatus: account.status,
    sendingDomains: sendingDomains.results.map(d => ({
      domain: d.domain,
      ownershipVerified: d.status?.ownership_verified,
      dkimStatus: d.status?.dkim_status,
    })),
  };
}

Webhook Validation — Three Different Signature Schemes

Mailgun, Resend, and SparkPost all deliver delivery events via HTTP webhooks, and each uses a different signature scheme. Implementing the wrong scheme produces 401-equivalent rejections from your own validation code — same symptom as a network or header issue, different cause.

Mailgun uses HMAC-SHA256 over timestamp + token — two Mailgun-specific fields in the webhook payload, not the HTTP headers. The signing key is separate from the API key (found in the Mailgun dashboard under Webhooks, not under API Keys). The signature field is hex-encoded. Reject payloads where |now - timestamp| > 300 to prevent replay attacks.

// Mailgun webhook validation
import { createHmac } from 'crypto';
function validateMailgunWebhook(signingKey, payload) {
  const { timestamp, token, signature } = payload['signature'];
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
    throw new Error('Mailgun webhook replay: timestamp too old');
  }
  const computed = createHmac('sha256', signingKey)
    .update(timestamp + token)
    .digest('hex');
  if (computed !== signature) throw new Error('Mailgun webhook signature mismatch');
}

Resend uses Svix infrastructure with three HTTP headers: svix-id, svix-timestamp, svix-signature. The signing payload is {svix-id}.{svix-timestamp}.{raw_body_string}. The webhook secret starts with whsec_ followed by a base64-encoded key — strip the prefix, then base64-decode before using as the HMAC key. The svix-signature header may contain multiple space-separated v1,{sig} values for key rotation; any match is valid. The body must be read as raw bytes (not parsed JSON) before validation — middleware that parses JSON first will invalidate the signature.

SparkPost uses a simpler HMAC-SHA256 scheme over the raw request body, with the signature in the X-MessageSystems-Batch-ID header or validated via the API key permission model. SparkPost's webhook validation checks that the event was generated for your account, rather than a cryptographic body signature — the webhook target URL should be kept secret, or use IP allowlisting for SparkPost's known webhook source IPs.

Regional Endpoint Selection — Mailgun and SparkPost Both Have US/EU Splits

Both Mailgun and SparkPost maintain separate infrastructure for US and EU data processing, with completely different base URLs. Using the wrong region returns a 404 (Mailgun) or 401 (SparkPost) — both of which look like configuration errors, not region errors. AWS SES is region-scoped at the SDK level (specify region: 'eu-west-1' for EU email sending), and sending identity verification is region-specific. Postmark and Resend have single global endpoints.

Provider US endpoint EU endpoint Wrong region error
Mailgun api.mailgun.net/v3 api.eu.mailgun.net/v3 404 "Domain not found"
Postmark api.postmarkapp.com (global) N/A
AWS SES region: 'us-east-1' (SDK) region: 'eu-west-1' (SDK) "Email address not verified" (identity is region-scoped)
Resend api.resend.com (global) N/A
SparkPost api.sparkpost.com/api/v1 api.eu.sparkpost.com/api/v1 401 (same as wrong key)

Monitoring Transactional Email MCP Servers With AliveMCP

The five failure modes that health probes must catch — wrong credential encoding, domain verification regression, sandbox mode active (SES), regional endpoint misconfiguration, and bounce rate exceeding inbox threshold — are all detectable before users encounter them. AliveMCP probes MCP server endpoints every 60 seconds and surfaces auth failures, credential drift, and endpoint health regressions in real time. For MCP tools that wrap transactional email APIs, the composite probe pattern from this post (auth + domain state + quota) should be registered as the endpoint's health check so AliveMCP can monitor all three layers simultaneously.

Each of the five providers covered in this post has a dedicated MCP tool guide with working code for auth, domain verification, bounce handling, and health probes: Mailgun, Postmark, AWS SES, Resend, and SparkPost. The AliveMCP blog covers similar integration patterns for CRM APIs, CI/CD tools, observability platforms, and the broader MCP ecosystem.