Email Drip Campaign Automation with Python and Mailchimp API
Most email marketing platforms let you build drip sequences through a drag-and-drop interface. So why would you use Python instead?
Because clicking through a UI to set up 47 slightly different campaigns for different customer segments isn't "easy" — it's tedious, error-prone, and impossible to version-control. When a campaign breaks, you have no audit trail. When you need to clone a sequence and modify it for a different segment, you're clicking through screens for hours. And when you want to dynamically adjust send timing based on user behaviour pulled from your CRM, the UI simply can't do it.
Email drip campaign automation with Python and the Mailchimp API gives you programmatic control over every part of the process: creating audiences, adding subscribers, building campaigns, scheduling sequences, and pulling open and click-rate data back into your own database. You write it once, version it in Git, and deploy it anywhere.
This tutorial walks through the complete setup — from API key to a working 4-email drip sequence — with all the code you need to get started today.
Prerequisites
You'll need:
- A Mailchimp account (free tier works for testing — up to 500 contacts)
- Python 3.8+
- A Mailchimp API key and your datacenter prefix (e.g.,
us21)
Install the official Mailchimp Marketing Python library:
1pip install mailchimp-marketing
Getting Your API Key and Datacenter
- Log into Mailchimp → click your account name (top right) → Profile
- Go to Extras → API keys → Create A Key
- Copy the key — it looks like
abc123def456....-us21 - The letters after the dash (
us21in this example) are your datacenter prefix — you'll need this for every API call
Store these as environment variables rather than hardcoding them in your scripts:
1export MAILCHIMP_API_KEY="your-api-key-here"2export MAILCHIMP_DC="us21"
Setting Up the Mailchimp Client
1import os2import mailchimp_marketing as MailchimpMarketing3from mailchimp_marketing.api_client import ApiClientError45def get_mailchimp_client():6 """Initialize and return an authenticated Mailchimp client."""7 client = MailchimpMarketing.Client()8 client.set_config({9 "api_key": os.environ.get("MAILCHIMP_API_KEY"),10 "server": os.environ.get("MAILCHIMP_DC", "us21"),11 })1213 # Verify connection14 try:15 response = client.ping.get()16 print(f"Mailchimp connected: {response}")17 except ApiClientError as error:18 print(f"Connection failed: {error.text}")19 raise2021 return client2223client = get_mailchimp_client()24# Output: Mailchimp connected: {'health_status': "Everything's Chimpy!"}
Always verify your connection before running any automation. The ping endpoint is the fastest way to confirm your API key and datacenter are correct.
Creating an Audience (List)
In Mailchimp's API, what the UI calls a "List" is called an "Audience" or list. Here's how to create one programmatically:
1def create_audience(client, name: str, from_name: str, reply_to: str) -> str:2 """3 Create a new Mailchimp audience.4 Returns the list_id of the newly created audience.5 """6 try:7 response = client.lists.create_list({8 "name": name,9 "permission_reminder": "You signed up for updates from our website.",10 "email_type_option": False,11 "contact": {12 "company": "Your Company Name",13 "address1": "123 Main Street",14 "city": "San Francisco",15 "state": "CA",16 "zip": "94102",17 "country": "US",18 },19 "campaign_defaults": {20 "from_name": from_name,21 "from_email": reply_to,22 "subject": "",23 "language": "EN",24 },25 })26 list_id = response["id"]27 print(f"Audience created: {name} (ID: {list_id})")28 return list_id29 except ApiClientError as error:30 print(f"Error creating audience: {error.text}")31 raise3233# Create a drip campaign audience34list_id = create_audience(35 client=client,36 name="Product Launch Drip Sequence",37 from_name="Jennifer from AutomateMyJob",38 reply_to="jennifer@automatemyjob.com"39)
Tip: For testing, create a dedicated test audience. Running drip sequence tests against your production audience will trigger emails to real subscribers.
Adding Subscribers Programmatically
You can add individual subscribers or batch-add a list at once. The batch method is far more efficient for bulk imports.
Single subscriber:
1import hashlib23def add_subscriber(client, list_id: str, email: str, first_name: str, last_name: str,4 tags: list = None) -> dict:5 """Add or update a subscriber in the audience."""6 # Mailchimp uses MD5 hash of lowercase email as subscriber ID7 subscriber_hash = hashlib.md5(email.lower().encode()).hexdigest()89 member_data = {10 "email_address": email,11 "status": "subscribed",12 "merge_fields": {13 "FNAME": first_name,14 "LNAME": last_name,15 },16 }1718 if tags:19 member_data["tags"] = [{"name": tag, "status": "active"} for tag in tags]2021 try:22 response = client.lists.set_list_member(list_id, subscriber_hash, member_data)23 print(f"Added: {email} ({response['status']})")24 return response25 except ApiClientError as error:26 print(f"Error adding subscriber {email}: {error.text}")27 raise2829# Add a subscriber with tags for segmentation30add_subscriber(31 client=client,32 list_id=list_id,33 email="jane.doe@example.com",34 first_name="Jane",35 last_name="Doe",36 tags=["trial-user", "source-webinar"]37)
Batch-add subscribers from a list:
1def batch_add_subscribers(client, list_id: str, subscribers: list[dict]) -> dict:2 """3 Batch add subscribers. Each subscriber dict should have:4 email, first_name, last_name, and optionally tags (list of strings).5 """6 members = []7 for sub in subscribers:8 member = {9 "email_address": sub["email"],10 "status": "subscribed",11 "merge_fields": {12 "FNAME": sub.get("first_name", ""),13 "LNAME": sub.get("last_name", ""),14 },15 }16 if sub.get("tags"):17 member["tags"] = [{"name": t, "status": "active"} for t in sub["tags"]]18 members.append(member)1920 response = client.lists.batch_list_members(list_id, {21 "members": members,22 "update_existing": True, # Update instead of error on duplicate email23 })2425 print(f"Added: {response['new_members']} | Updated: {response['updated_members']} "26 f"| Errors: {len(response['errors'])}")27 return response2829# Example usage30new_subscribers = [31 {"email": "alice@example.com", "first_name": "Alice", "last_name": "Smith",32 "tags": ["trial-user"]},33 {"email": "bob@example.com", "first_name": "Bob", "last_name": "Jones",34 "tags": ["paid-user", "source-organic"]},35]36batch_add_subscribers(client, list_id, new_subscribers)
Building the 4-Email Drip Sequence
A classic drip sequence for a product or service launch:
| Send Timing | Goal | |
|---|---|---|
| Welcome | Immediately on signup | Onboard, set expectations |
| Day 3 | 3 days after signup | Deliver first value tip |
| Day 7 | 7 days after signup | Social proof + engagement nudge |
| Day 14 | 14 days after signup | Conversion push / upgrade CTA |
In Mailchimp's API, each email in the sequence is a Campaign object. You create the campaign, set its content, then schedule it.
1def create_drip_campaign(client, list_id: str, subject: str, preview_text: str,2 from_name: str, reply_to: str, html_content: str,3 schedule_time: str = None) -> str:4 """5 Create a Mailchimp campaign.6 schedule_time: ISO 8601 datetime string (e.g., '2026-03-05T09:00:00+00:00')7 Pass None to save as draft.8 Returns the campaign_id.9 """10 # Step 1: Create the campaign object11 try:12 campaign = client.campaigns.create({13 "type": "regular",14 "recipients": {"list_id": list_id},15 "settings": {16 "subject_line": subject,17 "preview_text": preview_text,18 "title": f"Drip - {subject}",19 "from_name": from_name,20 "reply_to": reply_to,21 "auto_footer": False,22 },23 })24 campaign_id = campaign["id"]25 print(f"Campaign created: {campaign_id}")26 except ApiClientError as error:27 print(f"Error creating campaign: {error.text}")28 raise2930 # Step 2: Set the email content31 try:32 client.campaigns.set_content(campaign_id, {"html": html_content})33 print(f"Content set for campaign: {campaign_id}")34 except ApiClientError as error:35 print(f"Error setting content: {error.text}")36 raise3738 # Step 3: Schedule or keep as draft39 if schedule_time:40 try:41 client.campaigns.schedule(campaign_id, {"schedule_time": schedule_time})42 print(f"Campaign scheduled for: {schedule_time}")43 except ApiClientError as error:44 print(f"Error scheduling campaign: {error.text}")45 raise4647 return campaign_id
Now let's use this function to build the full 4-email sequence. The email bodies use Mailchimp's merge tag syntax — *|FNAME|* is automatically replaced with each subscriber's first name at send time (populated from the FNAME merge field you set when adding subscribers). You'll see these throughout the HTML templates below.
1from datetime import datetime, timedelta, timezone23def build_drip_sequence(client, list_id: str, base_date: datetime):4 """5 Create a complete 4-email drip sequence starting from base_date.6 base_date: datetime object for when the sequence should begin (Day 0).7 """8 from_name = "Jennifer at AutomateMyJob"9 reply_to = "jennifer@automatemyjob.com"1011 emails = [12 {13 "day_offset": 0,14 "subject": "Welcome to AutomateMyJob — here's what to expect 👋",15 "preview_text": "Your automation journey starts right now.",16 "html": """17 <h1>Welcome, *|FNAME|*!</h1>18 <p>You've just taken the best step toward getting your time back.</p>19 <p>Over the next two weeks, we'll send you our most practical automation guides — no fluff, just tools and techniques that work immediately.</p>20 <p>First up: check out our <a href="https://automatemyjob.com/posts/no-code-automation-tools-beginners">beginner's guide to no-code automation</a> to see where to start.</p>21 <p>See you in a few days,<br>Jennifer</p>22 """,23 },24 {25 "day_offset": 3,26 "subject": "The one automation that saves 5 hours a week (Day 3)",27 "preview_text": "Most people overlook this completely.",28 "html": """29 <h1>Hey *|FNAME|*, Day 3 tip incoming 🔧</h1>30 <p>If you do only one thing this week, automate your email sorting.</p>31 <p>With three Gmail filters and 20 minutes of setup, you can cut your32 daily email time by 30%. Here's exactly how to do it...</p>33 <p><a href="https://automatemyjob.com">Read the full guide →</a></p>34 <p>Jennifer</p>35 """,36 },37 {38 "day_offset": 7,39 "subject": "How Sarah saved 12 hours a week with Python automation",40 "preview_text": "Real story, real numbers.",41 "html": """42 <h1>A quick story, *|FNAME|* 📖</h1>43 <p>Sarah was spending 3 hours every Monday manually compiling reports44 from 6 different spreadsheets.</p>45 <p>One Python script later: 8 minutes. Every week. Automatically.</p>46 <p>We broke down exactly how she did it in this tutorial: <a href="https://automatemyjob.com/posts/automate-weekly-reports-python-ai-tutorial">Automate Your Weekly Reports with Python</a></p>47 <p>You can do the same thing. No prior Python experience needed.</p>48 <p>Jennifer</p>49 """,50 },51 {52 "day_offset": 14,53 "subject": "You've been with us 2 weeks — here's what comes next",54 "preview_text": "Ready to take automation seriously?",55 "html": """56 <h1>Two weeks in, *|FNAME|* 🎯</h1>57 <p>You've been learning about automation for two weeks now.58 Here's a question: what's the one task you do every week59 that you'd most like to eliminate?</p>60 <p>Reply to this email and tell us. We read every response and61 often turn reader questions into tutorials.</p>62 <p>And if you're ready to go deeper, our full automation course63 covers everything from beginner scripts to enterprise AI agents.64 <a href="https://automatemyjob.com">Explore the curriculum →</a>65 </p>66 <p>Thanks for being here,<br>Jennifer</p>67 """,68 },69 ]7071 campaign_ids = []72 for email in emails:73 send_time = base_date + timedelta(days=email["day_offset"])74 # Schedule at 9 AM UTC75 send_time = send_time.replace(hour=9, minute=0, second=0, microsecond=0,76 tzinfo=timezone.utc)77 schedule_str = send_time.strftime("%Y-%m-%dT%H:%M:%S+00:00")7879 campaign_id = create_drip_campaign(80 client=client,81 list_id=list_id,82 subject=email["subject"],83 preview_text=email["preview_text"],84 from_name=from_name,85 reply_to=reply_to,86 html_content=email["html"],87 schedule_time=schedule_str,88 )89 campaign_ids.append(campaign_id)90 print(f"Day {email['day_offset']} email scheduled: {schedule_str}")9192 return campaign_ids9394# Launch the sequence starting tomorrow95start_date = datetime.now(timezone.utc) + timedelta(days=1)96campaign_ids = build_drip_sequence(client, list_id, start_date)97print(f"\nDrip sequence ready: {len(campaign_ids)} campaigns scheduled")
Tracking Open Rates and Click Rates via API
After your campaigns start sending, pull performance data back into your own system for reporting:
1def get_campaign_stats(client, campaign_id: str) -> dict:2 """Retrieve open rate, click rate, and key metrics for a campaign."""3 try:4 report = client.reports.get_campaign_report(campaign_id)5 stats = {6 "campaign_id": campaign_id,7 "subject": report["campaign_title"],8 "emails_sent": report["emails_sent"],9 "open_rate": round(report["opens"]["open_rate"] * 100, 2),10 "click_rate": round(report["clicks"]["click_rate"] * 100, 2),11 "unsubscribe_count": report["unsubscribes"]["unsubscribe_count"],12 "bounce_count": report["bounces"]["hard_bounces"] + report["bounces"]["soft_bounces"],13 }14 return stats15 except ApiClientError as error:16 print(f"Error fetching report for {campaign_id}: {error.text}")17 return {}1819def get_drip_sequence_report(client, campaign_ids: list[str]) -> list[dict]:20 """Get a full performance report for every email in the drip sequence."""21 all_stats = []22 for i, campaign_id in enumerate(campaign_ids):23 stats = get_campaign_stats(client, campaign_id)24 if stats:25 stats["sequence_position"] = i + 126 all_stats.append(stats)27 print(f"Email {i+1}: {stats['open_rate']}% open rate | "28 f"{stats['click_rate']}% click rate | "29 f"{stats['emails_sent']} sent")30 return all_stats3132# Pull the report after campaigns have sent33report = get_drip_sequence_report(client, campaign_ids)
Segmenting Subscribers Based on Behaviour
One of the most powerful API-only features is dynamic segmentation — sending different follow-ups based on whether a subscriber opened, clicked, or ignored previous emails.
1def get_non_openers(client, campaign_id: str, list_id: str) -> list[str]:2 """3 Return email addresses of subscribers who were sent a campaign but didn't open it.4 Useful for sending a re-engagement or different follow-up to non-openers.5 """6 try:7 # Get all members who were sent this campaign8 sent_report = client.reports.get_email_activity_for_campaign(9 campaign_id, fields=["activity.email_address", "activity.action"]10 )1112 non_openers = []13 for member in sent_report.get("emails", []):14 actions = [a["action"] for a in member.get("activity", [])]15 if "open" not in actions:16 non_openers.append(member["email_address"])1718 print(f"Non-openers for campaign {campaign_id}: {len(non_openers)}")19 return non_openers2021 except ApiClientError as error:22 print(f"Error fetching activity: {error.text}")23 return []2425def tag_subscribers(client, list_id: str, emails: list[str], tag_name: str):26 """Apply a tag to a list of subscribers for segmentation."""27 members = []28 for email in emails:29 subscriber_hash = hashlib.md5(email.lower().encode()).hexdigest()30 members.append({31 "email_address": email,32 "status": "subscribed",33 "tags": [{"name": tag_name, "status": "active"}]34 })3536 # Use batch update for efficiency37 client.lists.batch_list_members(list_id, {38 "members": members,39 "update_existing": True,40 })41 print(f"Tagged {len(emails)} subscribers with '{tag_name}'")4243# Tag non-openers for a re-engagement campaign44non_openers = get_non_openers(client, campaign_ids[0], list_id)45if non_openers:46 tag_subscribers(client, list_id, non_openers, "day1-non-opener")
Error Handling and Reliability
Production drip automations need to handle API failures gracefully. Mailchimp's API returns ApiClientError with a JSON body describing the problem.
1import time2import json34def create_campaign_with_retry(client, payload: dict, max_retries: int = 3) -> dict:5 """6 Create a campaign with exponential backoff retry on transient errors.7 Retries on 429 (rate limit) and 5xx (server errors). Raises on 4xx.8 """9 for attempt in range(max_retries):10 try:11 return client.campaigns.create(payload)12 except ApiClientError as error:13 error_data = json.loads(error.text)14 status_code = error_data.get("status", 0)1516 if status_code == 429:17 # Rate limited — wait and retry18 wait_time = 2 ** attempt19 print(f"Rate limited. Waiting {wait_time}s before retry {attempt + 1}")20 time.sleep(wait_time)21 elif status_code >= 500:22 # Server error — retry23 wait_time = 2 ** attempt24 print(f"Server error ({status_code}). Retrying in {wait_time}s")25 time.sleep(wait_time)26 else:27 # Client error (400, 401, 404) — don't retry, raise immediately28 print(f"Client error ({status_code}): {error_data.get('detail')}")29 raise3031 raise RuntimeError(f"Failed after {max_retries} attempts")
Common error codes to handle:
| Status | Meaning | Action |
|---|---|---|
400 | Invalid input data | Check your payload fields |
401 | Invalid API key | Check your API key and datacenter |
403 | Forbidden | Check account permissions |
404 | Resource not found | Check list_id or campaign_id |
429 | Rate limit exceeded | Back off and retry |
500+ | Mailchimp server error | Retry with backoff |
Best Practices for Production Drip Campaigns
Always test with a seed list first. Create a test audience containing only your own email addresses and run every campaign through it before activating for real subscribers. This catches formatting issues, broken links, and scheduling mistakes before they reach your list.
Use Mailchimp merge tags for personalisation. The *|FNAME|* syntax in your HTML templates is replaced by Mailchimp with each subscriber's actual first name. Use merge tags liberally — personalised subject lines improve open rates by 10–20% on average.
Respect unsubscribe mechanics. Mailchimp handles unsubscribes automatically, but if you're adding subscribers via API from a CRM, make sure you're syncing unsubscribe status back. Adding a subscriber who previously unsubscribed is a CAN-SPAM/GDPR violation.
Don't schedule campaigns less than 24 hours in the future. Mailchimp requires at least 1 hour of lead time for scheduled campaigns, but 24 hours gives you a buffer to catch and correct mistakes before they send.
Monitor deliverability. Watch your bounce rate (keep hard bounces below 2%) and spam complaint rate (keep below 0.1%). High rates damage your sending reputation and can get your account suspended. Use Mailchimp's lists.delete_list_member to prune hard bounces promptly.
Conclusion
Programmatic drip campaigns with Python and the Mailchimp API are the right approach the moment you outgrow the manual UI: multiple segments, dynamic sequences, behavioural triggers, and automated reporting all become manageable when your campaigns are defined in code.
The eight code blocks in this tutorial give you everything you need for a production-ready setup: authentication, audience creation, bulk subscriber import, campaign building, scheduling, performance tracking, behavioural segmentation, and error handling with retry logic.
Start with the welcome email and Day 3 follow-up. Get those running and validated. Then layer on the Day 7 social-proof email and the Day 14 conversion push. Measure open and click rates at each step, iterate on the subject lines and copy, and you'll have a fully automated, optimised drip sequence running in under a week.
Frequently Asked Questions
Does the Mailchimp free plan support the API? Yes, the Mailchimp API is available on all plans including Free. The free plan allows up to 500 contacts and 1,000 email sends per month. For testing drip sequences, the free tier is perfectly adequate. For production campaigns with larger lists, you'll need a paid plan.
What's the difference between using Mailchimp's automation feature vs. the API?
Mailchimp's native automation (Customer Journeys) handles individual subscriber-level timing — each subscriber gets Email 1 when they join, then Email 2 three days later, etc. The API-based approach in this tutorial creates list-wide campaigns at fixed scheduled times, which is better for cohort-based launches. For true per-subscriber timing (triggered by individual signup date), use Mailchimp's Customer Journeys API endpoints (the /customer-journeys/journeys and /customer-journeys/journeys/{journey_id}/steps endpoints) or the Transactional API (Mandrill) for maximum flexibility. Note: type: "variate" in the campaigns API refers to A/B split testing, not subscriber-triggered automation.
Can I use this approach with SendGrid or Klaviyo instead of Mailchimp?
The concepts are identical, but the API syntax differs. SendGrid has a very similar Python SDK (sendgrid-python). Klaviyo's API is similarly structured with lists, profiles, and campaigns. The code patterns in this tutorial — creating audiences, adding subscribers, building campaigns, tracking stats — map directly to both platforms with different method names.
How do I stop a drip campaign from sending if a subscriber converts? Tag the converted subscriber (e.g., "converted") and use a condition check before each campaign send — either via Mailchimp segments (exclude the "converted" tag) or by running a pre-send script that unsubscribes converted users from the drip audience. The cleanest approach is to manage drip subscriptions via Mailchimp Groups or a dedicated segment, removing subscribers from the group the moment they convert.
Related articles: Python Email Automation and Bulk Sending, Marketing Automation ROI: How to Maximize Returns, Automated Lead Scoring for Marketing Teams
Sponsored Content
Interested in advertising? Reach automation professionals through our platform.
