Guide · Transactional Email

MCP Tools for SparkPost — API key auth without Bearer prefix, transmissions API, substitution data, suppression list

SparkPost (now operating as Bird after the MessageBird/SparkPost merger) is a high-volume transactional and marketing email platform used in MCP tools for notifications, digests, and triggered campaigns. The first thing that confuses developers: the Authorization header value is the raw API key — no "Bearer" prefix. Every other email API uses Bearer or Basic; SparkPost uses the key alone. The second major distinction: the core API resource is a "transmission", which combines recipients, template content, and metadata into one object — not a simple { from, to, subject, body }. And the third: the suppression list is a first-class API resource you can query, add to, and delete from — enabling proactive list hygiene before a campaign.

TL;DR

Auth: Authorization: {YOUR_API_KEY} — no "Bearer" prefix, just the key. US base: https://api.sparkpost.com/api/v1. EU base: https://api.eu.sparkpost.com/api/v1. Send: POST /transmissions with recipients array (each { address: { email } }), content.from, content.subject, content.text/content.html. Template send: add content.template_id and recipients[].substitution_data. Suppression check: GET /suppression-list/{email}. Batch limit: no hard batch limit but practical performance limit around 10,000 recipients per transmission. Response: { results: { total_accepted_recipients, total_rejected_recipients, id } }.

Authentication — the API key goes directly in Authorization

SparkPost's Authorization header contains just the API key — not Bearer {key}, not Basic base64(...), just the raw key string. This is non-standard and trips up developers who assume "Authorization header" means Bearer tokens. Sending Bearer {key} results in a 401 that looks identical to a wrong API key, making it frustrating to debug.

SparkPost API keys are created in the dashboard with per-resource grant types. For MCP tools that only send email, request only the Transmissions: Read/Write and Sending Domains: Read grants. Do not request Account: Read/Write or Suppression Lists: Read/Write unless your tool actively manages those resources.

// SparkPost client
class SparkPostClient {
  constructor(apiKey, region = 'us') {
    if (!apiKey) throw new Error('SparkPost API key is required');
    this.apiKey = apiKey;
    // Region selection: US vs EU infrastructure
    this.baseUrl = region === 'eu'
      ? 'https://api.eu.sparkpost.com/api/v1'
      : 'https://api.sparkpost.com/api/v1';
  }

  async request(method, path, body = null) {
    const res = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        // SparkPost: raw API key — NO "Bearer" prefix
        'Authorization': this.apiKey,
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
      body: body ? JSON.stringify(body) : null,
      signal: AbortSignal.timeout(30_000),
    });

    const data = await res.json();

    if (!res.ok) {
      // SparkPost error format: { errors: [{ message, code, description }] }
      const errors = data.errors || [{ message: 'unknown error' }];
      const msg = errors[0].description || errors[0].message;
      throw new Error(`SparkPost ${method} ${path} → ${res.status}: ${msg}`);
    }

    return data.results ?? data;
  }
}

Transmissions API — single message and bulk sends

The SparkPost transmission model combines recipients, content, and send options into a single API call. A transmission with one recipient is a transactional send; a transmission with thousands of recipients (one per subscription) is a campaign. Each recipient can have per-recipient substitution_data that overrides template variables — enabling personalization at scale in a single API call.

// Send a single email (one recipient, immediate)
async function sendEmail(client, { from, to, subject, text, html, tags = [] }) {
  const result = await client.request('POST', '/transmissions', {
    recipients: [
      { address: { email: to } },
    ],
    content: {
      from,
      subject,
      ...(text && { text }),
      ...(html && { html }),
    },
    // Tags for per-message event filtering
    metadata: Object.fromEntries(tags.map(t => [t.name, t.value])),
    // options: track opens, clicks, set campaign ID
    options: {
      click_tracking: true,
      open_tracking: true,
    },
  });

  return {
    transmissionId: result.id,
    accepted: result.total_accepted_recipients,
    rejected: result.total_rejected_recipients,
  };
}

// Bulk send with per-recipient substitution data
async function sendBulkWithTemplate(client, {
  from,
  templateId,
  recipients,
  campaignId,
}) {
  const result = await client.request('POST', '/transmissions', {
    campaign_id: campaignId,   // groups this transmission in SparkPost reporting
    recipients: recipients.map(r => ({
      address: {
        email: r.email,
        name: r.name,        // optional display name
      },
      // Per-recipient variables that override the template's substitution_data
      substitution_data: r.variables || {},
    })),
    content: {
      from,
      // template_id reference — content is fetched from the stored template
      template_id: templateId,
      // use_draft: false — always use the published version, not a draft
      use_draft: false,
    },
    options: {
      click_tracking: true,
      open_tracking: true,
      // start_time: ISO8601 — schedule delivery for a future time (up to 3 days)
    },
  });

  return {
    transmissionId: result.id,
    accepted: result.total_accepted_recipients,
    rejected: result.total_rejected_recipients,
  };
}

Substitution data — SparkPost template variables

SparkPost's template engine uses a double-brace syntax similar to Handlebars: {{variable}} for HTML-escaped output and {{{variable}}} for unescaped HTML. Beyond simple variable substitution, SparkPost templates support conditionals ({{if variable}}...{{else}}...{{end}}) and loops ({{each list}}...{{end}}). Template variables are defined at three levels: transmission-level substitution_data (applies to all recipients), recipient-level substitution_data (overrides transmission-level per recipient), and metadata (attached to events but not accessible in templates).

// Inline template with substitution data (no stored template needed)
async function sendTemplatedEmail(client, {
  from,
  recipients,
  subjectTemplate,
  htmlTemplate,
  globalVars = {},
}) {
  const result = await client.request('POST', '/transmissions', {
    // Transmission-level substitution_data — all recipients see these defaults
    substitution_data: globalVars,
    recipients: recipients.map(r => ({
      address: { email: r.email, name: r.name },
      // Recipient-level substitution_data overrides globalVars for this recipient
      substitution_data: r.vars || {},
    })),
    content: {
      from,
      // Template syntax: {{first_name}} substituted from substitution_data
      subject: subjectTemplate,
      html: htmlTemplate,
    },
  });

  return result;
}

// Example usage: personalized subject line and body
const results = await sendTemplatedEmail(client, {
  from: 'alerts@yourdomain.com',
  globalVars: { product_name: 'AliveMCP', support_url: 'https://alivemcp.com/support' },
  recipients: [
    {
      email: 'alice@example.com',
      name: 'Alice',
      vars: { first_name: 'Alice', server_name: 'my-mcp-server', status: 'down' },
    },
    {
      email: 'bob@example.com',
      name: 'Bob',
      vars: { first_name: 'Bob', server_name: 'another-server', status: 'degraded' },
    },
  ],
  subjectTemplate: '{{product_name}} Alert: {{server_name}} is {{status}}',
  htmlTemplate: '<p>Hi {{first_name}}, your server {{server_name}} is currently {{status}}.</p>',
});

Suppression list management

SparkPost maintains an account-level suppression list that blocks sends to addresses for one of three reasons: non_transactional (unsubscribed from marketing), transactional (opted out of all email), or automatic bounce/complaint suppression. Before a bulk transmission, check the suppression list proactively to avoid rejected recipients reducing your transmission's total_rejected_recipients count — which contributes to negative sending reputation metrics.

// Check if a single address is suppressed
async function checkSuppression(client, email) {
  try {
    const result = await client.request('GET', `/suppression-list/${encodeURIComponent(email)}`);
    return {
      suppressed: true,
      email: result.email,
      type: result.type,           // 'non_transactional' | 'transactional'
      source: result.source,       // 'Bounce Rule', 'Complaint', 'Manual'
      description: result.description,
      created: result.created,
      updated: result.updated,
    };
  } catch (err) {
    if (err.message.includes('404')) {
      return { suppressed: false, email };
    }
    throw err;
  }
}

// List suppressed addresses (paginated)
async function listSuppressions(client, { limit = 100, cursor, type } = {}) {
  const params = new URLSearchParams({ per_page: String(limit) });
  if (cursor) params.set('cursor', cursor);
  if (type) params.set('type', type);

  const result = await client.request('GET', `/suppression-list?${params}`);
  return {
    items: (result.results || []).map(s => ({
      email: s.email,
      type: s.type,
      source: s.source,
      description: s.description,
    })),
    cursor: result.links?.next, // follow next cursor for more pages
    total: result.total_count,
  };
}

// Add an address to the suppression list manually
async function addSuppression(client, email, type = 'non_transactional', description = '') {
  await client.request('PUT', `/suppression-list/${encodeURIComponent(email)}`, {
    recipients: [{
      email,
      type,              // 'non_transactional' | 'transactional'
      description,       // reason for suppression (visible in dashboard)
    }],
  });
}

// Remove an address from suppression list (re-enable sending)
async function removeSuppression(client, email) {
  await client.request('DELETE', `/suppression-list/${encodeURIComponent(email)}`);
}

Health monitoring for SparkPost MCP tools

SparkPost provides a sending domain verification API and account-level metrics. A health probe should verify: API key is valid, at least one sending domain is verified, and the account is not over its sending limit.

async function probeSparkPost(client, sendingDomain) {
  const results = await Promise.allSettled([

    // 1. API key validity — list sending domains (very lightweight)
    (async () => {
      const domains = await client.request('GET', '/sending-domains');
      const domainList = domains.results || [];
      return {
        kind: 'auth',
        totalDomains: domainList.length,
        verifiedDomains: domainList.filter(d => d.status?.ownership_verified).length,
      };
    })(),

    // 2. Sending domain verification status
    (async () => {
      const domain = await client.request('GET', `/sending-domains/${encodeURIComponent(sendingDomain)}`);
      const verified = domain.status?.ownership_verified && domain.status?.dkim_status === 'valid';
      if (!verified) {
        throw new Error(
          `Sending domain ${sendingDomain} not fully verified: ` +
          `ownership=${domain.status?.ownership_verified}, ` +
          `dkim=${domain.status?.dkim_status}`
        );
      }
      return {
        kind: 'domain',
        name: sendingDomain,
        ownershipVerified: domain.status?.ownership_verified,
        dkimStatus: domain.status?.dkim_status,
        complianceStatus: domain.status?.compliance_status,
      };
    })(),

    // 3. Recent usage metrics (confirms pipeline is not stalled)
    (async () => {
      const metrics = await client.request('GET', '/metrics/deliverability?precision=hour&limit=1');
      const last = (metrics.results || [])[0];
      return {
        kind: 'metrics',
        latestHour: last?.ts,
        sent: last?.count_sent ?? 0,
        delivered: last?.count_accepted ?? 0,
        bounces: last?.count_hard_bounce ?? 0,
      };
    })(),
  ]);

  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

Adding "Bearer" prefix to the Authorization header
SparkPost's Authorization header takes the raw API key with no prefix. Sending Bearer {key} results in a 401 that looks identical to a wrong key. The API error message in the response body does not distinguish between a wrong key and a misformatted key — making this one of the most common silent-misconfiguration bugs.
Putting the same address in both recipients and substitution_data
In SparkPost, the recipient address and per-recipient substitution data are sibling properties inside each recipient object ({ address: { email }, substitution_data: { ... } }), not separate top-level keys in the transmission. Mixing these up causes template variables to not be replaced, resulting in literal {{variable}} in sent emails.
Ignoring total_rejected_recipients in the transmission response
A SparkPost transmission response with HTTP 200 can still report total_rejected_recipients > 0. Rejected recipients include suppressed addresses, addresses with invalid syntax, and domain-blocked addresses. If you only log the 200 status and not the counts, you will miss partial failures silently.
Not tracking campaign_id for deliverability attribution
SparkPost's deliverability reports filter by campaign_id. Without setting it, all your transmissions aggregate into an undifferentiated "no-campaign" bucket, making it impossible to identify which type of email is causing bounce rate increases. Always set campaign_id to a meaningful category string ("password-reset", "alert", "weekly-digest").
Skipping suppression list checks before bulk sends
SparkPost counts rejected recipients (including suppressed addresses) against your sending reputation metrics. A bulk transmission with 5% of recipients suppressed looks like a 5% failure rate in deliverability dashboards. Pre-filtering the list using GET /suppression-list/{email} before sending keeps rejection rates low and reporting clean.

Related guides