Mailerflow Start free
Blog › Email Marketing Platforms & Alternatives › Mailchimp

Mailchimp API: Getting Started for Developers

Published October 22, 2026 · Updated October 22, 2026 · 9 min read

Illustration for Mailchimp API: Getting Started for Developers

The Mailchimp API is a REST interface authenticated with HTTP Basic using an API key that you generate in your account. You can be sending your first authenticated request in a few steps, and four operations that cover many common integrations are: list audiences, upsert a member, apply a tag, and let a journey pick that member up.

This guide covers prerequisites, authentication, a first request, member upserts, tagging, journey triggers, error handling with retries, idempotency, and a smoke test you can run before production.

What the Mailchimp API gives you

Mailchimp's Marketing API is a REST API over audiences, members, tags, campaigns and customer journeys. Mailchimp also documents a separate transactional sending product with its own credentials and endpoints — check the official API reference for the current split between the two before you build.

As of 2026-10-22, Mailchimp's marketing pricing page states: a 14-day free trial of the Standard plan, the ability to cancel or downgrade to Essentials or the basic Free plan at any time, a 15% saving for businesses with 10,000+ contacts on their first 12 months (kept if switching to Premium or Essentials), Standard allowing up to 6,000 emails per month, and custom plans for more contacts. Standard also includes generative AI features, actionable insights into audience growth and conversion funnels, enhanced automations, custom-coded email templates, customizable popup forms and personalized onboarding. Source: mailchimp.com/pricing/marketing, checked 2026-10-22.

Plan-tier availability of specific API capabilities is documented by Mailchimp rather than fixed here — check the official API reference and pricing page for what your plan exposes, especially around journeys and advanced automations.

If you are still deciding where to send from, see the Best Email Marketing Platforms compared side by side, with prices checked daily on each provider's own pricing page.

Prerequisites

Before writing code, get these in place:

  • A Mailchimp account with at least one audience created.
  • The audience (list) ID. Find it in the UI under Audience → Settings → Audience name and defaults, or read it from GET /lists.
  • A server-side runtime. The examples below target Node 18+ (global fetch and node:crypto). Python 3.10+ works equally well with requests.
  • Outbound HTTPS from wherever the code runs.
  • A secret store — environment variables, a secret manager, anything that isn't your repository.

Two things trip people up immediately. First, the base URL is datacenter-specific: https://<dc>.api.mailchimp.com/3.0, where <dc> is the part of your API key after the dash. A key ending in -us14 means your base URL is https://us14.api.mailchimp.com/3.0. Second, an API key is account-wide by default. Create a separate key per integration so you can revoke one without breaking the others, and note that Mailchimp shows the key only once at creation time.

Setup and authentication

Create a key in the Mailchimp UI: Account → Extras → API keys → Create A Key. Copy it immediately.

Authentication is HTTP Basic, with any non-empty string as the username and the key as the password:

curl -s -u "anystring:$MAILCHIMP_API_KEY" \
  "https://us14.api.mailchimp.com/3.0/ping"

Equivalently, as a raw header:

# base64 of "anystring:YOUR_KEY"
Authorization: Basic YW55c3RyaW5nOllPVVJfS0VZ

If your application acts on behalf of other Mailchimp accounts — a multi-tenant product, an agency dashboard — use OAuth2 instead of a single key. A personal API key is the right choice when you're integrating your own account.

Rate limits apply. Rather than hardcoding a number you read somewhere, read the rate-limit response headers Mailchimp documents and back off when the remaining count gets low. Mailchimp's official docs are the authority on the current limits and header names for your account.

Sending the first request

Ping first to confirm the key and datacenter, then list audiences to grab the ID you'll use everywhere else.

// node --env-file=.env first-request.js  (Node 20+)
// or: MAILCHIMP_API_KEY=... node first-request.js

const KEY = process.env.MAILCHIMP_API_KEY;
if (!KEY) throw new Error('MAILCHIMP_API_KEY is not set');

const DC = KEY.split('-').pop(); // "us14" from "abc123-us14"
const BASE = `https://${DC}.api.mailchimp.com/3.0`;

const auth = 'Basic ' + Buffer.from(`anystring:${KEY}`).toString('base64');

async function get(path) {
  const res = await fetch(`${BASE}${path}`, {
    headers: { Authorization: auth, Accept: 'application/json' },
  });
  const body = await res.json();
  if (!res.ok) {
    throw new Error(`${res.status} ${body.title}: ${body.detail}`);
  }
  return body;
}

const ping = await get('/ping');
console.log('ping:', ping.health_status);

const lists = await get('/lists?count=10');
for (const l of lists.lists) {
  console.log(l.id, l.name, l.stats.member_count);
}

Expected output shape (trimmed):

{
  "lists": [
    {
      "id": "a1b2c3d4e5",
      "name": "Newsletter",
      "stats": { "member_count": 4821 }
    }
  ]
}

Copy that id — it's your list_id for everything that follows.

Adding and updating members (upsert)

POST /lists/{list_id}/members creates a member. PUT /lists/{list_id}/members/{subscriber_hash} upserts, where subscriber_hash is the MD5 of the lowercased email address. The PUT is what you want in almost every case, because running it twice produces the same state rather than a duplicate error.

import { createHash } from 'node:crypto';

const subscriberHash = (email) =>
  createHash('md5').update(email.trim().toLowerCase()).digest('hex');

async function upsertMember({ listId, email, firstName, tags = [] }) {
  const hash = subscriberHash(email);
  const res = await fetch(`${BASE}/lists/${listId}/members/${hash}`, {
    method: 'PUT',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email_address: email,
      status_if_new: 'subscribed',
      merge_fields: { FNAME: firstName },
      tags,
    }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.title}: ${body.detail}`);
  return body;
}

Two details matter here. status_if_new sets the status only when the member doesn't exist yet — it won't overwrite the status of someone who already unsubscribed, which is exactly the behaviour you want. The set of accepted status values is defined in Mailchimp's API reference; check it there rather than hardcoding a list, since it can change. And if the audience has double opt-in enabled, a new member lands as pending until they confirm. That's an audience setting, not an API quirk — check it before you assume your write failed.

Tagging members

You can pass tags inside the member payload, but the dedicated endpoint is better because it's naturally idempotent:

async function tagMember({ listId, email, tagName }) {
  const hash = subscriberHash(email);
  const res = await fetch(`${BASE}/lists/${listId}/members/${hash}/tags`, {
    method: 'POST',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      tags: [{ name: tagName, status: 'active' }],
    }),
  });
  if (!res.ok) {
    const body = await res.json();
    throw new Error(`${res.status} ${body.title}: ${body.detail}`);
  }
}

Re-sending the same tag does nothing, so you can call this on every webhook without worrying about duplicates. Tags are a simple way to drive segmentation and journey entry conditions. Treat tag names as case-sensitive in practice: pick a convention like trial-started and stick to it, because Trial-Started and trial-started will behave as two different tags in your segments.

Triggering a journey

Be precise about what the API does here. Customer Journeys are configured in the Mailchimp UI, and the API's role is to add or update the member and set the tag or merge field that the journey's trigger condition watches. Journey-related endpoints exist for some operations, but trigger configuration is UI-side — check the current API reference for what your plan exposes.

The pattern is: upsert the member, apply the tag, let the journey pick it up.

await upsertMember({
  listId: process.env.MAILCHIMP_LIST_ID,
  email: 'ada@example.com',
  firstName: 'Ada',
});

await tagMember({
  listId: process.env.MAILCHIMP_LIST_ID,
  email: 'ada@example.com',
  tagName: 'trial-started',
});

console.log('member upserted and tagged; journey will pick it up');

Don't assume the journey fires synchronously. Treat it as an eventually-consistent side effect: your job is to make the member state correct, and the journey reacts to that state. If you need confirmation, read the member back and verify the tag is present rather than polling for an email.

Handling errors, retries and idempotency

Mailchimp returns structured error responses. The exact schema is documented in Mailchimp's API reference — read it there before you write parsing code, because the fields you rely on should match what the docs currently specify.

The statuses you'll actually hit: 400 for malformed payloads, 401 for a bad key, 403 for plan or permission problems, 404 for a wrong list ID or member hash, 429 when rate limited, and 5xx for transient failures.

Retry only what's worth retrying:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function withRetry(fn, { attempts = 4, base = 400 } = {}) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      const status = err.status;
      const retryable = status === 429 || (status >= 500 && status < 600);
      if (!retryable || i === attempts - 1) throw err;
      const jitter = Math.random() * base;
      await sleep(base * 2 ** i + jitter);
    }
  }
}

Attach err.status and err.retryAfter when you throw, and honour Retry-After when the response includes it. Never retry a 4xx — a 400 will fail identically on the second attempt and you'll just burn quota.

On idempotency: the PUT on the member hash is naturally idempotent, so make it your default write path. For creates that aren't keyed by email, keep your own dedupe key — your internal user ID, for instance — and check before posting. Log the status, the error detail field and any request ID on failure; that's usually enough to reconstruct what happened without a support ticket.

Testing and going to production

Don't test against your real audience. Create a dedicated test audience, use plus-alias addresses (you+test1@example.com) so you can generate as many distinct members as you need, and clean up with DELETE /lists/{list_id}/members/{subscriber_hash} when you're done.

A smoke test that catches common regressions:

const listId = process.env.MAILCHIMP_TEST_LIST_ID;
const email = `you+smoke${Date.now()}@example.com`;

await get('/ping');
await upsertMember({ listId, email, firstName: 'Smoke' });
await tagMember({ listId, email, tagName: 'smoke-test' });

const hash = subscriberHash(email);
const member = await get(`/lists/${listId}/members/${hash}`);
console.assert(member.email_address === email, 'member round-trip failed');
console.log('smoke test passed');

For production: store the key in a secret manager, use a different key per environment, log request IDs, alert on spikes in 401 and 403, which can indicate a rotated or revoked key, and batch writes where the API supports it. Mailchimp's official API reference is the source of truth when endpoints change.

Complete example: signup to tagged member

// signup.js — Node 18+
// env: MAILCHIMP_API_KEY, MAILCHIMP_LIST_ID
import { createHash } from 'node:crypto';

const KEY = process.env.MAILCHIMP_API_KEY;
const LIST_ID = process.env.MAILCHIMP_LIST_ID; // change to your audience
const TAG = 'trial-started';                  // change to your tag
const DC = KEY.split('-').pop();
const BASE = `https://${DC}.api.mailchimp.com/3.0`;
const AUTH = 'Basic ' + Buffer.from(`anystring:${KEY}`).toString('base64');

const hash = (email) =>
  createHash('md5').update(email.trim().toLowerCase()).digest('hex');

async function call(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: AUTH,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  });
  if (res.status === 204) return null;
  const body = await res.json();
  if (!res.ok) {
    const err = new Error(`${res.status} ${body.title}: ${body.detail}`);
    err.status = res.status;
    err.retryAfter = res.headers.get('retry-after');
    throw err;
  }
  return body;
}

async function addSignup({ email, firstName }) {
  const h = hash(email);
  const member = await call(`/lists/${LIST_ID}/members/${h}`, {
    method: 'PUT',
    body: JSON.stringify({
      email_address: email,
      status_if_new: 'subscribed',
      merge_fields: { FNAME: firstName }, // FNAME must exist in the audience
    }),
  });
  await call(`/lists/${LIST_ID}/members/${h}/tags`, {
    method: 'POST',
    body: JSON.stringify({ tags: [{ name: TAG, status: 'active' }] }),
  });
  return { id: member.id, status: member.status, tagged: TAG };
}

try {
  console.log(await addSignup({ email: 'ada@example.com', firstName: 'Ada' }));
} catch (err) {
  console.error('signup failed:', err.message, 'retry-after:', err.retryAfter);
  process.exitCode = 1;
}

Troubleshooting

Symptom Likely cause Fix
401 with a key that looks correct Base URL uses the wrong datacenter prefix Derive <dc> from the key suffix, not from a copied example
404 on a member endpoint Wrong list ID, or hash isn't MD5 of the lowercased email Log the hash and compare against a known member
Member stays pending Double opt-in enabled on the audience Confirm the audience setting; this is expected behaviour
400 mentioning merge_fields Field not created in the audience, or wrong type Create the merge field in the UI first, then match its tag exactly
429 under load Too many concurrent writes Back off with jitter, batch, or move writes off the request path
Journey doesn't fire Trigger condition doesn't match the tag or field you set, or the journey isn't published Re-read the member, verify the tag, check the journey is live

When the API work outgrows the plan

Before you migrate anything, check the official pricing page for contact tiers, monthly send limits and which features sit on which plan, and compare against your own numbers. Plan limits and per-contact pricing are the levers most teams end up weighing when they outgrow a tier.

If you want the same primitives under a different cost model, Mailerflow's REST API offers scoped keys, idempotency keys, an OpenAPI spec, code samples and a JS SDK, and every paid plan includes unlimited emails, with a Free plan at 2,500 contacts and 10,000 emails/month and paid plans from $14.90/month. CSV import handles duplicate and validity checks, custom fields and tags, and free migration help covers contacts, suppression lists, templates and core automations on eligible paid plans. See which plan fits your list size on the Mailerflow pricing page.

FAQ

Do I need OAuth or an API key? A personal API key is fine when you're integrating your own Mailchimp account. Use OAuth2 when your application acts on behalf of other accounts, such as a multi-tenant product or an agency tool.

Where do I find my audience/list ID? In the UI under Audience → Settings → Audience name and defaults, or by calling GET /lists and reading the id field. The API value is what you want.

Why is my member pending? The audience has double opt-in enabled, so new members stay pending until they confirm. It's an audience setting, not an API error.

How do I trigger a journey from code? Upsert the member and set the tag or merge field the journey's trigger watches. The journey itself is configured and published in the UI, so verify the trigger condition matches exactly.

Is the API available on the free plan? The API is reachable across plans, but some features depend on your tier. Check the official API reference and pricing page for what your plan exposes.

How do I avoid duplicate members? Use PUT /lists/{list_id}/members/{subscriber_hash} instead of POST. It's idempotent, so repeated calls converge on one member rather than creating duplicates.


Want the same API primitives with unlimited emails on paid plans? See Mailerflow pricing.