Guide · Transactional Email

MCP Tools for Resend — Bearer token auth, batch sends, domain verification, Svix webhook validation

Resend is a developer-first transactional email API designed for simplicity: standard REST with JSON payloads, a re_xxx Bearer token, and a clean response body that always includes the message ID. It is the cleanest API surface among the major transactional email providers, which makes it a natural fit for MCP tools. The operational constraints to understand upfront: API keys are prefixed with re_ and must be passed as Authorization: Bearer re_xxx; the sending domain must be verified via DNS before any send is accepted; batch sends are limited to 100 emails per request; and webhook events use Svix signature headers (svix-id, svix-timestamp, svix-signature) for validation — not a single HMAC field like Mailgun.

TL;DR

Auth: Authorization: Bearer re_your_api_key. Base URL: https://api.resend.com. Send: POST /emails with JSON body { from, to, subject, text, html }. Response: { id: "msg_uuid" }. Batch: POST /emails/batch with array of up to 100 email objects. Domain list: GET /domains. Domain check: GET /domains/{domain_id} — look for status: "verified". Webhook signing: validate svix-id, svix-timestamp, svix-signature headers using the Svix library or manual HMAC-SHA256. Rate limit: varies by plan (check X-RateLimit-Limit, X-RateLimit-Remaining response headers).

Authentication — Bearer token with re_ prefix

Resend uses standard Bearer token authentication. The API key format is re_ followed by a random string. Pass it in the Authorization header. Unlike Mailgun's Basic auth or Postmark's custom header, this is standard OAuth2-style Bearer — any HTTP client handles it without special configuration.

Resend supports multiple API keys per account, which lets you scope keys to specific environments or services. Create separate keys for development, staging, and production. Keys can be revoked individually without affecting other keys — always use separate keys for each MCP tool deployment.

// Resend client — minimal, standard REST
class ResendClient {
  constructor(apiKey) {
    if (!apiKey?.startsWith('re_')) {
      throw new Error('Resend API key must start with re_');
    }
    this.apiKey = apiKey;
    this.baseUrl = 'https://api.resend.com';
  }

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

    // Resend returns rate limit headers on every response
    const remaining = res.headers.get('X-RateLimit-Remaining');
    const limit = res.headers.get('X-RateLimit-Limit');
    const reset = res.headers.get('X-RateLimit-Reset');

    if (res.status === 429) {
      const retryAfter = res.headers.get('Retry-After') || reset;
      throw new Error(`Resend rate limit exceeded — retry after ${retryAfter}`);
    }

    const data = await res.json();

    if (!res.ok) {
      // Resend error format: { statusCode, name, message }
      throw new Error(`Resend ${method} ${path} → ${res.status} ${data.name}: ${data.message}`);
    }

    return { data, rateLimit: { remaining, limit, reset } };
  }
}

// Send a single email
async function sendEmail(client, { from, to, subject, text, html, replyTo, tags = [] }) {
  const { data } = await client.request('POST', '/emails', {
    from,
    to: Array.isArray(to) ? to : [to],
    subject,
    ...(text && { text }),
    ...(html && { html }),
    ...(replyTo && { reply_to: replyTo }),
    // Tags: array of { name, value } pairs for event filtering
    ...(tags.length && { tags }),
  });
  // response: { id: "msg_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }
  return { messageId: data.id };
}

Batch sends — up to 100 emails per request

Resend's batch endpoint accepts an array of email objects and processes them as a single API call. The limit is 100 emails per request. For larger batches, chunk the list and send in parallel (respect rate limits — check X-RateLimit-Remaining before issuing concurrent requests).

// Batch send — up to 100 emails per call
async function sendBatch(client, emails) {
  if (emails.length > 100) {
    throw new Error(`Batch size ${emails.length} exceeds Resend limit of 100 — chunk the array`);
  }

  const { data } = await client.request('POST', '/emails/batch', emails.map(e => ({
    from: e.from,
    to: Array.isArray(e.to) ? e.to : [e.to],
    subject: e.subject,
    ...(e.text && { text: e.text }),
    ...(e.html && { html: e.html }),
    ...(e.replyTo && { reply_to: e.replyTo }),
  })));

  // data: { data: [{ id: "msg_xxx" }, { id: "msg_yyy" }, ...] }
  return data.data.map((r, i) => ({
    email: Array.isArray(emails[i].to) ? emails[i].to[0] : emails[i].to,
    messageId: r.id,
  }));
}

// Chunked batch send for large lists
async function sendLargeBatch(client, emails, batchSize = 100) {
  const results = [];
  for (let i = 0; i < emails.length; i += batchSize) {
    const chunk = emails.slice(i, i + batchSize);
    const batchResults = await sendBatch(client, chunk);
    results.push(...batchResults);
    // Brief pause between chunks to avoid rate limit
    if (i + batchSize < emails.length) {
      await new Promise(r => setTimeout(r, 200));
    }
  }
  return results;
}

Domain verification

Before any email can be sent, the from address domain must be verified in Resend. Domain verification requires adding DNS records: a TXT record for SPF, CNAME records for DKIM (Resend provides the specific record values), and optionally a CNAME for the tracking subdomain. A domain in pending or not_started state cannot send email — all sends from that domain return an error.

// List all domains and their verification status
async function listDomains(client) {
  const { data } = await client.request('GET', '/domains');
  return (data.data || []).map(d => ({
    id: d.id,
    name: d.name,
    status: d.status,       // 'not_started' | 'pending' | 'verified' | 'failed' | 'temporary_failure'
    isVerified: d.status === 'verified',
    region: d.region,       // 'us-east-1' | 'eu-west-1' etc. — determines SES region used
    createdAt: d.created_at,
  }));
}

// Get detailed DNS records needed for verification
async function getDomainRecords(client, domainId) {
  const { data } = await client.request('GET', `/domains/${domainId}`);
  return {
    id: data.id,
    name: data.name,
    status: data.status,
    records: data.records.map(r => ({
      type: r.record_type,    // 'TXT', 'CNAME', 'MX'
      name: r.name,
      value: r.value,
      ttl: r.ttl,
      status: r.status,       // 'verified' | 'not_started' | 'failed'
      priority: r.priority,   // for MX records
    })),
  };
}

// Trigger re-verification of a domain's DNS records
async function verifyDomain(client, domainId) {
  const { data } = await client.request('POST', `/domains/${domainId}/verify`);
  return { id: data.id, name: data.name, status: data.status };
}

Webhook validation — Svix signature headers

Resend uses Svix for webhook delivery, which means the signature scheme is different from Mailgun's single HMAC field. Svix signs payloads with three headers: svix-id (unique delivery ID), svix-timestamp (Unix seconds), and svix-signature (base64-encoded HMAC-SHA256 of svix-id.svix-timestamp.body using your webhook secret). Install the Svix library or implement the validation manually.

import { createHmac } from 'crypto';

// Manual Svix webhook validation (no external dependency)
function validateResendWebhook(webhookSecret, headers, rawBody) {
  const msgId = headers['svix-id'];
  const msgTimestamp = headers['svix-timestamp'];
  const msgSignature = headers['svix-signature'];

  if (!msgId || !msgTimestamp || !msgSignature) {
    return { valid: false, reason: 'missing svix headers' };
  }

  // Reject webhooks older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - parseInt(msgTimestamp, 10));
  if (age > 300) {
    return { valid: false, reason: `webhook too old: ${Math.floor(age)}s` };
  }

  // Svix signs: "{msgId}.{msgTimestamp}.{rawBody}"
  const signedContent = `${msgId}.${msgTimestamp}.${rawBody}`;

  // Webhook secret is prefixed with "whsec_" and base64-encoded
  // Strip the prefix and decode before using as HMAC key
  const secretBytes = Buffer.from(
    webhookSecret.startsWith('whsec_')
      ? webhookSecret.slice(6)
      : webhookSecret,
    'base64'
  );

  const computedSig = createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  // svix-signature may contain multiple signatures separated by spaces (for key rotation)
  // A webhook is valid if ANY of the provided signatures matches
  const signatures = msgSignature.split(' ').map(s => s.replace(/^v1,/, ''));
  const isValid = signatures.some(sig => {
    if (sig.length !== computedSig.length) return false;
    // Use constant-time comparison
    const a = Buffer.from(sig);
    const b = Buffer.from(computedSig);
    return a.length === b.length && !Buffer.compare(a, b);
  });

  return { valid: isValid, reason: isValid ? null : 'signature mismatch' };
}

// Resend webhook event handler (Express)
app.post('/webhooks/resend', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const validation = validateResendWebhook(
    process.env.RESEND_WEBHOOK_SECRET,
    req.headers,
    rawBody
  );

  if (!validation.valid) {
    return res.status(401).json({ error: validation.reason });
  }

  const event = JSON.parse(rawBody);
  const eventType = event.type;  // 'email.sent', 'email.delivered', 'email.bounced', 'email.complained', etc.

  if (eventType === 'email.bounced') {
    const { email_id, from, to, subject } = event.data;
    // Resend does not include bounce type classification — implement your own tracking
    console.log(`Bounce: ${to} for email ${email_id}`);
  }

  if (eventType === 'email.complained') {
    const { to } = event.data;
    // Immediately suppress spam complainers
    console.log(`Complaint: ${to} — suppress permanently`);
  }

  res.status(200).json({ received: true });
});

Health monitoring for Resend MCP tools

Resend does not have a dedicated health endpoint. A minimal probe: verify the API key is valid, check that at least one domain is in verified state, and confirm the sending domain for the integration is verified.

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

    // 1. API key validity — list domains is the lightest authenticated call
    (async () => {
      const domains = await listDomains(client);
      const verified = domains.filter(d => d.isVerified);
      return { kind: 'auth', totalDomains: domains.length, verifiedDomains: verified.length };
    })(),

    // 2. Sending domain verification
    (async () => {
      const domains = await listDomains(client);
      const domain = domains.find(d => d.name === sendingDomain);
      if (!domain) {
        throw new Error(`Domain ${sendingDomain} not found in Resend account`);
      }
      if (!domain.isVerified) {
        throw new Error(`Domain ${sendingDomain} is ${domain.status} — not ready to send`);
      }
      return { kind: 'domain', name: domain.name, status: domain.status, region: domain.region };
    })(),
  ]);

  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

Missing the re_ prefix check on startup
Resend API keys always start with re_. If configuration is missing or the environment variable contains a placeholder value, the error from the API is a generic 401 without mentioning the prefix. A startup assertion (key.startsWith('re_')) provides a clearer error message and prevents accidentally sending with a misconfigured key from a different provider.
Not reading the raw request body for webhook validation
Svix HMAC validation requires the raw request bytes — not the JSON-parsed object. Using express.json() middleware before the webhook handler causes signature mismatch because JSON parsing + re-serialization alters whitespace and key order. Always use express.raw() or equivalent for webhook routes and parse the body manually after validation.
Sending from an unverified domain
Resend returns an error if the from domain is not verified, but the error message says "The domain is not verified" rather than naming the domain. If your MCP tool sends from multiple domains, check each one individually before sending.
Treating batch results as all-or-nothing
The /emails/batch endpoint can partially succeed: some messages in the array are queued successfully while others fail (e.g., if one recipient address is malformed). The response is an array of results, not a single status. Check each element's presence of an id field to confirm individual success.
No bounce handling because Resend has no bounce-type field
Resend's email.bounced webhook event does not include a bounceType field distinguishing hard from soft bounces. This differs from Postmark's detailed classification. To avoid suppressing soft-bounce addresses permanently, implement your own retry policy: suppress after two bounces within 30 days rather than on first bounce.

Related guides