Automate Gmail templated replies and follow-up sequences using the Gmail API's users.messages.send endpoint. Messages must be RFC 2822-formatted and base64url-encoded (not standard base64). Threading requires matching In-Reply-To and References headers plus the original threadId. Hard sending cap: 500 messages/day for @gmail.com, 2,000/day for Google Workspace accounts.
API Quick Reference
OAuth 2.0
15,000 quota units/minute per user; 500 or 2,000 emails/day hard cap
JSON
Available
Understanding the Gmail API
The Gmail API is a RESTful service for programmatic Gmail access. The messages.send endpoint accepts RFC 2822-formatted email encoded as a base64url string in the 'raw' field — this is the primary gotcha that trips up most developers. Standard base64 uses '+' and '/' characters that are URL-unsafe; base64url replaces them with '-' and '_' respectively.
For threaded replies (essential for follow-up sequences), you must include three components: the In-Reply-To header with the original message's Message-ID, the References header with the full chain of Message-IDs, and the threadId field in the API request body. Missing any one of these causes Gmail to create a new thread instead of appending to the existing conversation.
The gmail.send scope is listed as Sensitive in Google's scope table, but in practice Gmail content scopes follow Restricted-level policy and require OAuth verification plus a security assessment if you store email content server-side. Official docs: https://developers.google.com/workspace/gmail/api
https://gmail.googleapis.com/gmail/v1Setting Up Gmail API Authentication
Gmail uses OAuth 2.0 with user consent for sending email. The gmail.send scope allows sending but not reading messages — add gmail.readonly or gmail.modify if you need to fetch thread data for constructing replies. Access tokens expire after 1 hour; the client libraries handle refresh automatically using the stored refresh token.
- 1Go to https://console.cloud.google.com and create or select a project
- 2Navigate to APIs & Services → Library → search 'Gmail API' → Enable
- 3Go to APIs & Services → OAuth consent screen. Configure app name, support email, and scopes
- 4Add scopes: https://www.googleapis.com/auth/gmail.send and https://www.googleapis.com/auth/gmail.readonly
- 5Go to Credentials → Create Credentials → OAuth Client ID → select Desktop app
- 6Download credentials JSON as credentials.json
- 7Install dependencies: pip install google-api-python-client google-auth-oauthlib google-auth-httplib2
- 8Run the authentication script once to complete OAuth flow and save token.json
1import os2from google.oauth2.credentials import Credentials3from google_auth_oauthlib.flow import InstalledAppFlow4from google.auth.transport.requests import Request5from googleapiclient.discovery import build67SCOPES = [8 'https://www.googleapis.com/auth/gmail.send',9 'https://www.googleapis.com/auth/gmail.readonly'10]1112def get_gmail_service():13 creds = None14 if os.path.exists('token.json'):15 creds = Credentials.from_authorized_user_file('token.json', SCOPES)16 if not creds or not creds.valid:17 if creds and creds.expired and creds.refresh_token:18 creds.refresh(Request())19 else:20 flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)21 creds = flow.run_local_server(port=0)22 with open('token.json', 'w') as f:23 f.write(creds.to_json())24 return build('gmail', 'v1', credentials=creds)Security notes
- •Never commit credentials.json or token.json to version control — add both to .gitignore
- •The gmail.send scope requires OAuth verification before your app can be used by external users
- •Do not share refresh tokens across users — each user must have their own OAuth token
- •For Google Workspace server automation, use Service Account + Domain-Wide Delegation
- •Monitor sending patterns — Gmail's abuse detection may suspend accounts with unusual bulk sending behavior
Key endpoints
/gmail/v1/users/{userId}/messages/sendSends a new email or reply. The message body must be a complete RFC 2822 email encoded as base64url. For replies, include the threadId field to keep messages in the same thread.
| Parameter | Type | Required | Description |
|---|---|---|---|
userId | string | required | The user's email address or 'me' for authenticated user |
raw | string | required | Complete RFC 2822 message encoded as base64url (NOT standard base64) |
threadId | string | optional | Thread ID to reply into — required for proper threading |
Request
1{"raw":"RnJvbTogc2VuZGVyQGV4YW1wbGUuY29tClRvOiByZWNpcGllbnRAZXhhbXBsZS5jb20KU3ViamVjdDogSGVsbG8KCkJvZHkgdGV4dA==","threadId":"18c3a2b1d4e5f6a7"}Response
1{"id":"18d1f2e3c4b5a697","threadId":"18c3a2b1d4e5f6a7","labelIds":["SENT"]}/gmail/v1/users/{userId}/messages/{id}Retrieves a full message including headers needed for constructing a reply — Message-ID, In-Reply-To, References, and threadId.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | required | The message ID from messages.list |
format | string | optional | Message format: 'full' (default), 'minimal', 'raw', or 'metadata' |
metadataHeaders | array | optional | When format=metadata, list of headers to include |
Response
1{"id":"18c3a2b1d4e5f6a7","threadId":"18c3a2b1d4e5f6a7","labelIds":["INBOX","UNREAD"],"payload":{"headers":[{"name":"Message-ID","value":"<abc123@mail.gmail.com>"},{"name":"From","value":"sender@example.com"},{"name":"Subject","value":"Re: Your question"}]}}/gmail/v1/users/{userId}/messagesLists messages matching a search query. Returns only {id, threadId} — use messages.get for full content. Use this to find the original message for constructing replies.
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | optional | Gmail search query (e.g., 'from:customer@example.com is:unread') |
maxResults | number | optional | Maximum messages to return, 1-500 |
pageToken | string | optional | Pagination token from previous response |
Response
1{"messages":[{"id":"18c3a2b1d4e5f6a7","threadId":"18c3a2b1d4e5f6a7"}],"resultSizeEstimate":1}/gmail/v1/users/{userId}/draftsCreates a draft message that can be sent later. Useful for scheduling follow-ups — create the draft now, send via drafts.send at the right time.
| Parameter | Type | Required | Description |
|---|---|---|---|
message.raw | string | required | RFC 2822 message encoded as base64url |
message.threadId | string | optional | Thread ID to associate the draft with |
Request
1{"message":{"raw":"RnJvbTogc2VuZGVyQGV4YW1wbGUuY29tClRvOiByZWNpcGllbnRAZXhhbXBsZS5jb20KU3ViamVjdDogRm9sbG93IFVwCgpCb2R5IHRleHQ="}}Response
1{"id":"r8765432109876543","message":{"id":"18d5e6f7a8b9c0d1","threadId":"18d5e6f7a8b9c0d1","labelIds":["DRAFT"]}}Step-by-step automation
Build RFC 2822 Message with Correct base64url Encoding
Why: Gmail's API rejects standard base64 — the '+' and '/' characters cause request parsing failures or garbled messages.
Construct a properly-formatted RFC 2822 email string with required headers (From, To, Subject, MIME-Version, Content-Type), then encode it using base64url encoding specifically. Python's base64.urlsafe_b64encode() handles this correctly. For UTF-8 subjects with non-ASCII characters, use RFC 2047 encoding: =?UTF-8?B?<base64>?=.
1# Build and encode a message2MESSAGE=$(python3 -c "3import base644msg = '''From: you@gmail.com5To: recipient@example.com6Subject: Following up7MIME-Version: 1.08Content-Type: text/plain; charset=UTF-8910Hi, just following up on my previous message.11'''12encoded = base64.urlsafe_b64encode(msg.encode()).decode()13print(encoded)14")1516curl -X POST \17 -H "Authorization: Bearer $ACCESS_TOKEN" \18 -H "Content-Type: application/json" \19 -d "{\"raw\":\"$MESSAGE\"}" \20 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'Pro tip: Always test your base64url encoding by decoding it back and checking the result. A common mistake is using standard base64 which works for ASCII-only content but silently corrupts non-ASCII characters in subjects or bodies.
Expected result: A message object with the 'raw' field containing the base64url-encoded RFC 2822 email, ready to pass to messages.send.
Fetch Original Message for Threading Data
Why: Proper email threading requires the original message's Message-ID header — without it, your reply starts a new conversation thread in the recipient's inbox.
When replying to an existing message, first fetch it using messages.get to extract the Message-ID header, existing References header chain, and the threadId. Request format=metadata with specific headers to minimize quota cost (5 units vs 5 units — same, but less data transferred). The threadId is in the message object at the top level, not in headers.
1# Fetch message headers for threading (cost: 5 quota units)2curl -H "Authorization: Bearer $ACCESS_TOKEN" \3 'https://gmail.googleapis.com/gmail/v1/users/me/messages/18c3a2b1d4e5f6a7?format=metadata&metadataHeaders=Message-ID&metadataHeaders=References&metadataHeaders=Subject&metadataHeaders=From'Pro tip: The 'Re: ' prefix in Subject is cosmetic in Gmail — Gmail threads by Message-ID/References chains, not Subject matching. Still add 'Re: ' for compatibility with other email clients.
Expected result: Returns an object with threadId, messageId (for In-Reply-To header), references chain, and the original subject — all needed for constructing a properly threaded reply.
Send the Templated Reply or Initial Outreach
Why: This is the actual send call — quota cost is 100 units, and each send counts against your daily sending limit.
Call messages.send with the built message object. The daily sending limits are hard caps enforced by Gmail infrastructure: 500 messages/day for @gmail.com accounts and 2,000/day for Google Workspace users. Exceeding these returns a 429 error. Via the API, each message can have a maximum of 100 recipients. Plan your sending schedule to stay within limits — for bulk outreach, Workspace accounts give you 4x the headroom.
1# Send the message2curl -X POST \3 -H "Authorization: Bearer $ACCESS_TOKEN" \4 -H "Content-Type: application/json" \5 -d "$MESSAGE_JSON" \6 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'Pro tip: HTTP 200 does not guarantee delivery — Gmail may still silently fail near the daily sending limit. Track sent message IDs and verify they appear in the Sent folder via messages.list.
Expected result: Returns {id, threadId, labelIds: ['SENT']}. The message appears in Gmail's Sent folder immediately.
Schedule Follow-up Sequences with Drafts
Why: Creating drafts now and sending them later via drafts.send is more reliable than keeping messages in memory — drafts survive server restarts and process crashes.
For multi-day follow-up sequences, create all drafts upfront with their scheduled send dates stored in your database. Use a cron job or scheduler to call drafts.send at the right time. The drafts.create endpoint costs 10 quota units; drafts.send costs 100 units (same as messages.send). Store the draft ID and scheduled_at timestamp, then poll for due drafts in your scheduler.
1# Create a draft for later sending2curl -X POST \3 -H "Authorization: Bearer $ACCESS_TOKEN" \4 -H "Content-Type: application/json" \5 -d '{"message":{"raw":"<base64url-encoded-RFC2822>"}}' \6 'https://gmail.googleapis.com/gmail/v1/users/me/drafts'78# Send a draft by ID9curl -X POST \10 -H "Authorization: Bearer $ACCESS_TOKEN" \11 -H "Content-Type: application/json" \12 -d '{"id":"r8765432109876543"}' \13 'https://gmail.googleapis.com/gmail/v1/users/me/drafts/send'Pro tip: Drafts don't auto-send — you must call drafts.send explicitly. Consider using a simple cron job with a database table of {draft_id, send_at, lead_id} rather than building complex in-memory scheduling.
Expected result: Returns an array of draft objects with IDs and scheduled send timestamps. Store these in your database and use a cron job to send drafts when their scheduled time arrives.
Complete working code
This script implements a 3-touch follow-up sequence: sends an initial outreach email, then creates two follow-up drafts scheduled for days 3 and 7. It reads leads from a CSV file, respects the daily sending limit with a counter, and properly threads all follow-ups in the original conversation.
1#!/usr/bin/env python32"""Gmail Follow-up Sequence Automator."""3import os4import csv5import base646import time7import logging8from datetime import datetime, timedelta9from email.mime.text import MIMEText10from email.mime.multipart import MIMEMultipart11from email.header import Header12from googleapiclient.discovery import build13from google.oauth2.credentials import Credentials14from google_auth_oauthlib.flow import InstalledAppFlow15from google.auth.transport.requests import Request1617logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')18log = logging.getLogger(__name__)1920SCOPES = ['https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.readonly']21DAILY_LIMIT = 450 # Stay safely under the 500/day @gmail.com cap2223TEMPLATES = {24 'initial': 'Hi {name},\n\nI noticed {company} and thought we might be able to help with {pain_point}.\n\nOpen to a quick 15-minute chat?\n\nBest, Me',25 'followup_3': 'Hi {name},\n\nJust following up on my last note. Still happy to connect if timing works.\n\nBest, Me',26 'followup_7': 'Hi {name},\n\nLast note from my end — reach out any time if this becomes relevant.\n\nBest, Me'27}2829def get_service():30 creds = None31 if os.path.exists('token.json'):32 creds = Credentials.from_authorized_user_file('token.json', SCOPES)33 if not creds or not creds.valid:34 if creds and creds.expired and creds.refresh_token:35 creds.refresh(Request())36 else:37 flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)38 creds = flow.run_local_server(port=0)39 with open('token.json', 'w') as f: f.write(creds.to_json())40 return build('gmail', 'v1', credentials=creds)4142def encode_message(sender, to, subject, body, reply_to=None, references=None, thread_id=None):43 msg = MIMEText(body, 'plain', 'utf-8')44 msg['From'] = sender45 msg['To'] = to46 msg['Subject'] = str(Header(subject, 'utf-8'))47 if reply_to:48 msg['In-Reply-To'] = reply_to49 msg['References'] = references or reply_to50 raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()51 result = {'raw': raw}52 if thread_id: result['threadId'] = thread_id53 return result5455def main():56 service = get_service()57 sent_today = 05859 with open('leads.csv') as f:60 for lead in csv.DictReader(f):61 if sent_today >= DAILY_LIMIT:62 log.warning('Daily limit reached, stopping')63 break64 try:65 # Send initial email66 body = TEMPLATES['initial'].format(**lead)67 msg = encode_message('me@gmail.com', lead['email'],68 f"Quick question for {lead['company']}", body)69 sent = service.users().messages().send(userId='me', body=msg).execute()70 thread_id = sent['threadId']71 sent_today += 172 log.info(f'Sent initial to {lead["email"]} (thread: {thread_id})')7374 # Create follow-up drafts75 for days, key in [(3, 'followup_3'), (7, 'followup_7')]:76 follow_body = TEMPLATES[key].format(**lead)77 follow_msg = encode_message(78 'me@gmail.com', lead['email'],79 f"Re: Quick question for {lead['company']}",80 follow_body, thread_id=thread_id81 )82 service.users().drafts().create(userId='me', body={'message': follow_msg}).execute()83 log.info(f'Draft created for day {days} follow-up to {lead["email"]}')8485 time.sleep(1.5) # ~40 sends/min max for safety86 except Exception as e:87 log.error(f'Failed for {lead["email"]}: {e}')88 continue8990if __name__ == '__main__':91 main()Error handling
Invalid base64 for 'raw'The 'raw' field contains standard base64 instead of base64url encoding. Characters '+' and '/' are URL-unsafe and rejected by the Gmail API parser.
Use base64.urlsafe_b64encode() in Python or replace + with - and / with _ after standard base64 encoding in JavaScript.
No retry — fix the encoding in your code and resubmit.
User-rate limit exceeded (Mail sending)Hit the daily sending cap: 500 messages/day for @gmail.com or 2,000/day for Workspace. This is a hard infrastructure limit, not a quota unit limit.
Stop sending for the day — the limit resets at midnight in the account's timezone. For higher volume, upgrade to Google Workspace. Build a send counter into your automation.
Wait until next day. Do not retry immediately — the limit is per-day, not per-minute.
Request had insufficient authentication scopesOAuth token lacks gmail.send scope. If you also need to read threads for reply threading, you need gmail.readonly as well.
Delete token.json, add the required scopes to your SCOPES list, and re-run the OAuth flow to get a new token.
No retry — user must re-authorize.
Failed to parse 'From' headerThe From header in the RFC 2822 message doesn't match the authenticated user's email address, or the header format is malformed.
Ensure the From address exactly matches the Google account being used. Use 'Name <email@domain.com>' format or bare email address — both are valid.
No retry — fix the From header and resubmit.
Backend ErrorTransient Gmail infrastructure error.
Implement exponential backoff and retry. These errors are temporary and typically resolve within a few seconds.
Exponential backoff: 2^n seconds (1s, 2s, 4s, 8s, 16s, 32s, max 64s) with jitter.
Rate Limits for Gmail API
| Scope | Limit | Window |
|---|---|---|
| Per user | 15,000 quota units | per minute |
| Per project | 1,200,000 quota units | per minute |
| Daily send cap (@gmail.com) | 500 messages | per day (resets midnight) |
| Daily send cap (Workspace) | 2,000 messages | per day per user |
| Recipients per message (API) | 100 recipients | per message |
1import time2import random3from googleapiclient.errors import HttpError45def send_with_backoff(service, message_body, max_retries=5):6 for attempt in range(max_retries):7 try:8 return service.users().messages().send(9 userId='me', body=message_body10 ).execute()11 except HttpError as e:12 if e.resp.status in [500, 503]:13 wait = min((2 ** attempt) + random.uniform(0, 1), 64)14 time.sleep(wait)15 elif e.resp.status == 429:16 # Daily limit hit — don't retry17 raise Exception('Daily sending limit exceeded')18 else:19 raise20 raise Exception('Max retries exceeded')- Track daily sent count in your database and stop at 450/day for @gmail.com (10% buffer below the 500 cap)
- Add 1-2 second delays between sends to avoid hitting per-minute quota limits
- Batch follow-up drafts creation rather than sending immediately — separate creation from delivery timing
- For outreach at scale, use a Google Workspace account (2,000/day vs 500/day for personal accounts)
- messages.send costs 100 quota units — at 15,000 units/minute user limit, max theoretical send rate is 150 messages/minute, but daily limits kick in first
Security checklist
- Store OAuth credentials outside your repository with .gitignore — credentials.json and token.json contain sensitive keys
- Never send from an email address that doesn't match the authenticated Google account
- Add unsubscribe headers to outreach sequences to comply with CAN-SPAM and GDPR
- Implement a suppression list to prevent sending to contacts who have replied, unsubscribed, or bounced
- The gmail.send scope requires OAuth verification for external apps — complete this before production use
- Rate-limit your automation to stay well under daily sending caps — sudden spikes trigger Gmail's abuse detection
- Log all sent message IDs for audit trail and bounce tracking
- Avoid sending identical messages to many recipients — Gmail's spam filters flag repetitive bulk patterns
Automation use cases
Sales Outreach Drip
intermediateSend a personalized cold email followed by 2-3 follow-ups over 14 days to a prospect list, with all messages threaded in the same conversation.
Customer Onboarding Sequence
intermediateTrigger a multi-step onboarding email series when a new user signs up, sending tips and check-ins at day 1, 3, and 7.
Support Ticket Auto-Reply
advancedSend an immediate templated acknowledgment when a support email arrives, then follow up automatically if no human response is sent within 24 hours.
Invoice Follow-up Reminder
beginnerAutomatically send payment reminder emails at net-30, net-45, and net-60 days for unpaid invoices, threaded in the original invoice email chain.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier (100 tasks/month); Starter from $19.99/monthZapier's Gmail + delay actions allow building basic follow-up sequences triggered by new email or form submissions without code.
- + No code needed
- + Easy template editing
- + Integrates with 500+ apps for triggers
- - No native send delay (requires paid Paths + delay steps)
- - Limited templating compared to code
- - 500/1500 task limits on lower tiers
Make (Integromat)
Free (1,000 ops/month); Core from $9/monthMake supports Gmail send actions with variable substitution and can schedule follow-ups using sleep modules and Google Sheets as a state store.
- + Visual sequence builder
- + Better pricing than Zapier at scale
- + Native sleep/delay support
- - More complex to configure
- - Free tier limited to 1,000 ops/month
- - Steeper learning curve
n8n
Free self-hosted; Cloud from €20/monthSelf-hosted n8n provides Gmail nodes for constructing and sending RFC 2822 messages with full control over threading headers and scheduling.
- + Free self-hosted
- + Full RFC 2822 header control
- + Schedule workflows as cron jobs
- - Requires server setup
- - More technical configuration
- - Gmail credentials still need OAuth setup
Best practices
- Always use base64url encoding for the 'raw' field — test by decoding your encoded string before the first production send
- Include proper threading headers (In-Reply-To, References) for all follow-up messages — this is what keeps replies in the same thread
- Build a suppression list: before each send, check if the recipient has replied or unsubscribed, and skip them
- Cap your automation at 80% of the daily limit to leave headroom for manual emails sent from the same account
- Store draft IDs in a database rather than memory — your follow-up scheduler needs to survive restarts
- Add the List-Unsubscribe header to comply with CAN-SPAM: List-Unsubscribe: <mailto:unsub@yourdomain.com>
- Test RFC 2822 messages with a single recipient before bulk runs — encoding errors don't show up until the API call
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm using the Gmail API in Python to send threaded follow-up emails. My initial send works but follow-up emails start new threads instead of replying to the original. I'm passing threadId in the message body and adding In-Reply-To header. Here's my message construction code: [paste code]. What am I missing to make the replies actually thread correctly in Gmail?
Build a Gmail follow-up sequence manager dashboard. Features: 1) Upload a CSV of leads with name, email, company fields, 2) Create and preview email templates with merge fields, 3) Configure sequence timing (day 1, day 3, day 7), 4) Dashboard showing sequence status per lead (sent/pending/replied), 5) One-click pause for leads who reply. Use Supabase for state management and a Supabase Edge Function to call the Gmail API with OAuth 2.0.
Frequently asked questions
Why do my follow-up emails start new threads instead of replying to the original?
You need three things for proper threading: the In-Reply-To header set to the original message's Message-ID value, the References header containing the chain of all previous Message-IDs, and the threadId field in the API request body. Missing any one of them causes Gmail to create a new thread. Fetch the original message with format=metadata to get its Message-ID header.
What is the Gmail API daily sending limit?
Personal @gmail.com accounts: 500 messages per day, max 500 recipients per email. Google Workspace accounts: 2,000 messages per day per user, max 100 recipients per message via API (not 500 for API calls). Exceeding these limits returns a 429 error. The limits reset at midnight in the account's timezone. These are hard infrastructure limits that cannot be raised.
Why does Gmail reject my message with a 400 error about base64?
The Gmail API requires base64url encoding, not standard base64. The difference: base64url replaces '+' with '-' and '/' with '_'. In Python, use base64.urlsafe_b64encode(). In JavaScript, after standard btoa(), replace all + with - and / with _. Standard base64 encoding of ASCII-only content happens to work sometimes because those characters don't appear, but non-ASCII content will fail.
Can I send from a different email address (alias) than the authenticated account?
Only if the alias is configured in Gmail Settings → Accounts → Send mail as. The From header must match either the primary account or a verified send-as alias. Using an unverified From address results in a 400 error. You can fetch the list of valid send-as addresses with users.settings.sendAs.list.
What happens when I hit the rate limit on messages.send?
You'll receive a 429 or 403 error. If it's the daily sending cap (500/day for Gmail, 2,000/day for Workspace), stop sending for the day — the limit resets at midnight. If it's the per-minute quota (15,000 units/min, with send costing 100 units = max 150 sends/minute), implement exponential backoff starting at 1 second, doubling each attempt, max 64 seconds.
Is the Gmail API free for sending email?
Yes, the Gmail API is free with no per-message charges. You pay only for any Google Cloud infrastructure (like Pub/Sub if you use push notifications). The sending limits (500 or 2,000/day) are the practical constraint, not cost.
Can RapidDev help build a custom Gmail email automation system?
Yes. RapidDev has built 600+ apps including sales automation and support systems using the Gmail API. We handle OAuth setup, RFC 2822 message construction, threading, rate limit management, and building the dashboard to manage sequences. Contact us for a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation