Skip to main content
Business & GrowthNewsletter Marketing Services308 lines

Mailchimp

"Mailchimp: email marketing platform API, audience lists, campaigns, automations, segments, templates, analytics, REST API v3"

Quick Summary10 lines
You are an expert in integrating Mailchimp for email marketing and newsletters.

## Key Points

- **Use PUT for subscriber upserts**: The `PUT /lists/{id}/members/{hash}` endpoint creates or updates in one call. Avoid separate GET-then-POST flows.
- **Prefer batch endpoints for bulk operations**: The `/lists/{id}` POST endpoint handles up to 500 members at once, far more efficient than individual calls.
- **Cache the subscriber hash**: Computing MD5 hashes is cheap, but consistent hashing (always lowercase the email first) prevents duplicate member records.
- **Forgetting the data center prefix**: The API key format is `<key>-<dc>`. Omitting the `dc` portion when constructing the base URL causes all requests to fail silently.
skilldb get newsletter-marketing-services-skills/MailchimpFull skill: 308 lines
Paste into your CLAUDE.md or agent config

Mailchimp — Newsletter & Email Marketing

You are an expert in integrating Mailchimp for email marketing and newsletters.

Core Philosophy

Mailchimp is the most widely adopted email marketing platform with a mature REST API (v3) at https://<dc>.api.mailchimp.com/3.0/, where <dc> is the data center prefix from your API key (e.g., us21). It uses HTTP Basic Auth with any username and the API key as the password. Subscribers belong to audiences (formerly lists), and segmentation happens through tags, segments, and groups. The API enforces a 10 concurrent connection limit. Design integrations that respect batch operation endpoints, use webhooks for real-time sync, and handle the member hash (MD5 of lowercase email) for subscriber lookups.

Setup & Configuration

Authentication and Client Setup

import crypto from "crypto";

interface MailchimpConfig {
  apiKey: string; // format: "<key>-<dc>"
  baseUrl?: string;
}

class MailchimpClient {
  private readonly baseUrl: string;
  private readonly headers: Record<string, string>;

  constructor(config: MailchimpConfig) {
    const dc = config.apiKey.split("-").pop();
    this.baseUrl =
      config.baseUrl ?? `https://${dc}.api.mailchimp.com/3.0`;
    this.headers = {
      Authorization: `Basic ${Buffer.from(
        `anystring:${config.apiKey}`
      ).toString("base64")}`,
      "Content-Type": "application/json",
    };
  }

  async request<T>(
    method: string,
    path: string,
    body?: unknown
  ): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const res = await fetch(url, {
      method,
      headers: this.headers,
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!res.ok) {
      const error = await res.json().catch(() => ({}));
      throw new Error(
        `Mailchimp API ${res.status}: ${JSON.stringify(error)}`
      );
    }
    return res.json() as Promise<T>;
  }

  /** MD5 hash of lowercase email, required for member endpoints */
  static subscriberHash(email: string): string {
    return crypto
      .createHash("md5")
      .update(email.toLowerCase())
      .digest("hex");
  }
}

const client = new MailchimpClient({
  apiKey: process.env.MAILCHIMP_API_KEY!,
});

Verify Connection

async function verifyConnection(client: MailchimpClient): Promise<void> {
  const root = await client.request<{
    account_name: string;
    email: string;
  }>("GET", "/");
  console.log(
    `Connected to Mailchimp: ${root.account_name} (${root.email})`
  );
}

Core Patterns

Subscriber (Member) Management

interface Member {
  id: string;
  email_address: string;
  status: "subscribed" | "unsubscribed" | "cleaned" | "pending" | "transactional";
  merge_fields: Record<string, string>;
  tags: Array<{ id: number; name: string }>;
  list_id: string;
}

async function upsertMember(
  client: MailchimpClient,
  listId: string,
  email: string,
  opts?: {
    status?: Member["status"];
    mergeFields?: Record<string, string>;
    tags?: string[];
  }
): Promise<Member> {
  const hash = MailchimpClient.subscriberHash(email);
  const res = await client.request<Member>(
    "PUT",
    `/lists/${listId}/members/${hash}`,
    {
      email_address: email,
      status_if_new: opts?.status ?? "subscribed",
      merge_fields: opts?.mergeFields,
    }
  );

  // Tags must be managed separately
  if (opts?.tags?.length) {
    await client.request(
      "POST",
      `/lists/${listId}/members/${hash}/tags`,
      {
        tags: opts.tags.map((name) => ({ name, status: "active" })),
      }
    );
  }

  return res;
}

async function getMember(
  client: MailchimpClient,
  listId: string,
  email: string
): Promise<Member> {
  const hash = MailchimpClient.subscriberHash(email);
  return client.request<Member>(
    "GET",
    `/lists/${listId}/members/${hash}`
  );
}

async function archiveMember(
  client: MailchimpClient,
  listId: string,
  email: string
): Promise<void> {
  const hash = MailchimpClient.subscriberHash(email);
  await client.request("DELETE", `/lists/${listId}/members/${hash}`);
}

Campaign Creation and Sending

interface Campaign {
  id: string;
  type: "regular" | "plaintext" | "absplit" | "rss";
  status: "save" | "paused" | "schedule" | "sending" | "sent";
  settings: {
    subject_line: string;
    from_name: string;
    reply_to: string;
  };
}

async function createAndSendCampaign(
  client: MailchimpClient,
  opts: {
    listId: string;
    subject: string;
    fromName: string;
    replyTo: string;
    htmlContent: string;
    segmentId?: number;
  }
): Promise<Campaign> {
  // 1. Create campaign
  const campaign = await client.request<Campaign>("POST", "/campaigns", {
    type: "regular",
    recipients: {
      list_id: opts.listId,
      segment_opts: opts.segmentId
        ? { saved_segment_id: opts.segmentId }
        : undefined,
    },
    settings: {
      subject_line: opts.subject,
      from_name: opts.fromName,
      reply_to: opts.replyTo,
    },
  });

  // 2. Set content
  await client.request("PUT", `/campaigns/${campaign.id}/content`, {
    html: opts.htmlContent,
  });

  // 3. Send
  await client.request("POST", `/campaigns/${campaign.id}/actions/send`);

  return campaign;
}

Batch Operations for Bulk Imports

async function bulkUpsertMembers(
  client: MailchimpClient,
  listId: string,
  members: Array<{
    email: string;
    mergeFields?: Record<string, string>;
    tags?: string[];
  }>
): Promise<{ created: number; updated: number; errors: number }> {
  const res = await client.request<{
    new_members: unknown[];
    updated_members: unknown[];
    errors: unknown[];
  }>("POST", `/lists/${listId}`, {
    members: members.map((m) => ({
      email_address: m.email,
      status_if_new: "subscribed",
      merge_fields: m.mergeFields ?? {},
      tags: m.tags ?? [],
    })),
    update_existing: true,
  });

  return {
    created: res.new_members.length,
    updated: res.updated_members.length,
    errors: res.errors.length,
  };
}

Campaign Reports

async function getCampaignReport(
  client: MailchimpClient,
  campaignId: string
): Promise<{
  emails_sent: number;
  opens: number;
  unique_opens: number;
  open_rate: number;
  clicks: number;
  click_rate: number;
  unsubscribed: number;
  bounce_rate: number;
}> {
  const res = await client.request<{
    emails_sent: number;
    opens: { opens_total: number; unique_opens: number; open_rate: number };
    clicks: { clicks_total: number; click_rate: number };
    unsubscribed: number;
    bounces: { hard_bounces: number; soft_bounces: number };
  }>("GET", `/reports/${campaignId}`);

  const totalBounces = res.bounces.hard_bounces + res.bounces.soft_bounces;
  return {
    emails_sent: res.emails_sent,
    opens: res.opens.opens_total,
    unique_opens: res.opens.unique_opens,
    open_rate: res.opens.open_rate,
    clicks: res.clicks.clicks_total,
    click_rate: res.clicks.click_rate,
    unsubscribed: res.unsubscribed,
    bounce_rate: res.emails_sent > 0 ? totalBounces / res.emails_sent : 0,
  };
}

Best Practices

  • Use PUT for subscriber upserts: The PUT /lists/{id}/members/{hash} endpoint creates or updates in one call. Avoid separate GET-then-POST flows.
  • Prefer batch endpoints for bulk operations: The /lists/{id} POST endpoint handles up to 500 members at once, far more efficient than individual calls.
  • Cache the subscriber hash: Computing MD5 hashes is cheap, but consistent hashing (always lowercase the email first) prevents duplicate member records.

Common Pitfalls

  • Forgetting the data center prefix: The API key format is <key>-<dc>. Omitting the dc portion when constructing the base URL causes all requests to fail silently.
  • Managing tags in the member upsert call: Tags cannot be set via the PUT member endpoint. They require a separate POST to the /tags sub-resource. Attempting to include tags in the upsert body silently ignores them.

Anti-Patterns

Using the service without understanding its pricing model. Cloud services bill differently — per request, per GB, per seat. Deploying without modeling expected costs leads to surprise invoices.

Hardcoding configuration instead of using environment variables. API keys, endpoints, and feature flags change between environments. Hardcoded values break deployments and leak secrets.

Ignoring the service's rate limits and quotas. Every external API has throughput limits. Failing to implement backoff, queuing, or caching results in dropped requests under load.

Treating the service as always available. External services go down. Without circuit breakers, fallbacks, or graceful degradation, a third-party outage becomes your outage.

Coupling your architecture to a single provider's API. Building directly against provider-specific interfaces makes migration painful. Wrap external services in thin adapter layers.

Install this skill directly: skilldb add newsletter-marketing-services-skills

Get CLI access →