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
Authorizationheader takes the raw API key with no prefix. SendingBearer {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 setcampaign_idto 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
- MCP tools for Mailgun — Basic auth with api: prefix, regional endpoints, webhook HMAC, bounces
- MCP tools for Postmark — X-Postmark-Server-Token auth, message streams, bounce classification
- 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 server health check patterns — composite endpoints and failure classification
- MCP server uptime monitoring — probes, intervals, and alert routing