Automate personalized sales outreach emails using Gmail API's users.messages.send with RFC 2822 MIME messages encoded as base64url. Hard daily limits: 500 messages/day for @gmail.com, 2,000/day for Google Workspace, max 100 recipients per message via API. The gmail.send scope is governed by Restricted-level policy requiring OAuth verification. Plan send volume accordingly — there is no paid upgrade to raise these limits.
API Quick Reference
OAuth 2.0
500/day @gmail.com | 2,000/day Workspace | 100 recipients/message
JSON
Available
Understanding the Gmail API
The Gmail API's messages.send endpoint is the programmatic equivalent of clicking Send in Gmail. For sales outreach automation, you construct RFC 2822-formatted emails with personalized merge fields (name, company, pain point), encode them as base64url, and POST them to the API. The critical constraint is Gmail's daily sending limit — 500 messages for @gmail.com accounts and 2,000 for Workspace — which is a hard infrastructure cap, not a quota-unit limit.
Each outreach message sent via the API counts against this daily limit. At 100 leads/day on a personal @gmail.com account, you'll hit the cap in 5 days. Google Workspace gives you 4x the headroom but still caps at 2,000/day per user. For genuine bulk email marketing at scale, Gmail API is not the right tool — use a dedicated ESP like SendGrid or Mailchimp. Gmail API is ideal for personalized 1:1-style outreach to warm leads.
The gmail.send scope is listed as Sensitive but follows Restricted-level policy, requiring OAuth verification before external users can authorize your app. For internal use in a single Workspace domain, verification is not required. Official docs: https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send
https://gmail.googleapis.com/gmail/v1Setting Up Gmail API Authentication
Gmail uses OAuth 2.0 with user consent for sending. Access tokens expire after 1 hour; the client library refreshes them automatically using the stored refresh token. For sales outreach automation running unattended, complete the OAuth flow once interactively and save the refresh token.
- 1Go to https://console.cloud.google.com and create or select a project
- 2Enable Gmail API: APIs & Services → Library → Gmail API → Enable
- 3Configure OAuth consent screen: APIs & Services → OAuth consent screen → External → fill required fields
- 4Add scopes: https://www.googleapis.com/auth/gmail.send and https://www.googleapis.com/auth/gmail.readonly
- 5Create OAuth Client ID: Credentials → Create Credentials → OAuth Client ID → Desktop app
- 6Download credentials.json
- 7Run auth script once interactively to save token.json with refresh token
- 8Store credentials.json and token.json securely outside version control
1import os2from google.oauth2.credentials import Credentials3from google_auth_oauthlib.flow import InstalledAppFlow4from google.auth.transport.requests import Request5from googleapiclient.discovery import build67SCOPES = ['https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.readonly']89def get_service():10 creds = None11 if os.path.exists('token.json'):12 creds = Credentials.from_authorized_user_file('token.json', SCOPES)13 if not creds or not creds.valid:14 if creds and creds.expired and creds.refresh_token:15 creds.refresh(Request())16 else:17 flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)18 creds = flow.run_local_server(port=0)19 with open('token.json', 'w') as f:20 f.write(creds.to_json())21 return build('gmail', 'v1', credentials=creds)Security notes
- •Add credentials.json and token.json to .gitignore immediately — these contain OAuth secrets
- •The gmail.send scope requires OAuth verification before use by external users
- •Never hardcode lead email addresses in source code — read from CSV or database
- •Monitor sending patterns — sending identical content to many recipients triggers Gmail spam filters
- •Implement a suppression list before each send to avoid mailing to unsubscribed or bounced contacts
Key endpoints
/gmail/v1/users/{userId}/messages/sendSends an outreach email. The raw field must be a complete RFC 2822 email encoded as base64url. Costs 100 quota units and counts against the daily sending limit.
| Parameter | Type | Required | Description |
|---|---|---|---|
userId | string | required | The sender's email or 'me' |
raw | string | required | RFC 2822 email encoded as base64url (not standard base64) |
threadId | string | optional | For follow-ups: thread ID to reply into |
Request
1{"raw":"RnJvbTogb3V0cmVhY2hAY29tcGFueS5jb20KVG86IGxlYWRAZXhhbXBsZS5jb20KU3ViamVjdDogUXVpY2sgcXVlc3Rpb24K"}Response
1{"id":"18d1f2e3c4b5a697","threadId":"18d1f2e3c4b5a697","labelIds":["SENT"]}/gmail/v1/users/{userId}/messagesCheck for replies or bounces from previous outreach by searching with 'from:' and 'in:anywhere' queries. Returns only {id, threadId} — use messages.get for content.
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | optional | Gmail search syntax, e.g. 'from:lead@example.com in:anywhere' |
maxResults | number | optional | Max results, 1-500 |
Response
1{"messages":[{"id":"18c3a2b1","threadId":"18c3a2b1"}],"resultSizeEstimate":3}/gmail/v1/users/{userId}/labelsCreate tracking labels like 'Outreach/Sent', 'Outreach/Replied', 'Outreach/Bounced' to organize outreach threads in your inbox.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | required | Label name — use '/' for nesting (e.g., 'Outreach/Sent') |
messageListVisibility | string | optional | 'show' or 'hide' |
Request
1{"name":"Outreach/Sent","messageListVisibility":"show","labelListVisibility":"labelShow"}Response
1{"id":"Label_OutreachSent","name":"Outreach/Sent","type":"user"}Step-by-step automation
Load Lead List and Personalize Templates
Why: Personalized outreach dramatically outperforms generic blasts — the merge field logic is built before any API calls.
Read your lead list from a CSV or database. For each lead, substitute merge fields ({{name}}, {{company}}, {{pain_point}}) in the template body and subject. Store sent message IDs to enable follow-up threading. Track which leads have already been contacted in this campaign to prevent duplicate sends.
1# Build personalized message for one lead2python3 -c "3import base644name, company, email = 'Alice', 'Acme Corp', 'alice@acme.com'5body = f'''Hi {name},67I noticed {company} is hiring engineers rapidly — often that means scaling pains in your dev tooling.89We help teams like yours automate repetitive workflows. Open to a 15-minute chat this week?1011Best, Me12'''13msg = f'From: me@gmail.com\r\nTo: {email}\r\nSubject: Quick question for {company}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{body}'14print(base64.urlsafe_b64encode(msg.encode()).decode())15"Pro tip: Vary your subject lines across leads — sending the exact same subject to hundreds of people increases spam filter probability. Include the lead's company name or a specific detail.
Expected result: An array of lead objects each with a personalized message built and ready to send. No API calls yet.
Send Emails with Daily Limit Tracking
Why: Exceeding the daily sending cap causes all subsequent sends to fail with 429 — build a counter before you start the loop.
Send each personalized email via messages.send, tracking the count. Stop sending when you approach the daily limit. Persist the sent state (message ID, lead email, thread ID, sent timestamp) to a database or CSV — you'll need this for follow-up threading. Add a 1-2 second delay between sends to avoid per-minute quota issues.
1# Send one personalized message2curl -X POST \3 -H "Authorization: Bearer $ACCESS_TOKEN" \4 -H "Content-Type: application/json" \5 -d "{\"raw\":\"$ENCODED_MSG\"}" \6 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'Pro tip: Save campaign state after every send, not at the end — if your script crashes at message 200, you don't want to resend 1-199 next run.
Expected result: Returns a state object with sent message IDs and thread IDs per lead. Campaign state is persisted to disk after each send, surviving process restarts.
Check for Replies and Update Suppression List
Why: Sending follow-ups to leads who already replied (positively or negatively) is one of the most damaging mistakes in outreach automation.
Run a reply check before each campaign run: for each sent lead, search for messages from their email address in INBOX. If found, mark them as 'replied' in your state file and skip them in future follow-ups. This check costs 5 quota units per messages.list call.
1# Check for replies from a specific lead2curl -H "Authorization: Bearer $ACCESS_TOKEN" \3 'https://gmail.googleapis.com/gmail/v1/users/me/messages?q=from:lead@company.com+in:inbox&maxResults=1'Pro tip: Run the reply check at the START of your daily outreach run, before sending any messages. This ensures you never send a follow-up to someone who replied overnight.
Expected result: Updates the campaign state with 'replied: true' for leads who have responded. These leads are automatically excluded from future follow-up sends.
Apply Tracking Labels to Sent Messages
Why: Labels turn your Gmail inbox into a lightweight CRM, letting you visually track which leads are in which stage.
After sending, apply a custom label (e.g., 'Outreach/Sent Day 1') to the sent message. Use messages.modify with the message ID from the send response. Create nested labels for different stages: 'Outreach/Sent', 'Outreach/Follow-up 1', 'Outreach/Replied', 'Outreach/Closed'.
1# Apply tracking label to sent message2curl -X POST \3 -H "Authorization: Bearer $ACCESS_TOKEN" \4 -H "Content-Type: application/json" \5 -d '{"addLabelIds":["Label_OutreachSent"]}' \6 'https://gmail.googleapis.com/gmail/v1/users/me/messages/18d1f2e3c4b5a697/modify'Pro tip: Use nested labels (created with a '/' separator in the name) to build a hierarchy like Outreach > Sent, Outreach > Replied, Outreach > Closed. Gmail renders these as a folder tree in the sidebar.
Expected result: Sent messages appear in Gmail under the 'Outreach/Sent' label, giving you a visual CRM view directly in Gmail.
Complete working code
This complete script loads leads from CSV, checks for existing replies, sends personalized outreach emails with daily limit enforcement, persists state to disk, and applies tracking labels. Run daily via cron to work through your lead list within Gmail's sending caps.
1#!/usr/bin/env python32"""Gmail Sales Outreach Automator — personalized sends with daily limit tracking."""3import os4import csv5import base646import json7import time8import logging9from datetime import date10from email.mime.text import MIMEText11from email.header import Header12from googleapiclient.discovery import build13from google.oauth2.credentials import Credentials14from google_auth_oauthlib.flow import InstalledAppFlow15from google.auth.transport.requests import Request16from googleapiclient.errors import HttpError1718logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')19log = logging.getLogger(__name__)2021SCOPES = ['https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.readonly',22 'https://www.googleapis.com/auth/gmail.labels']23SENDER_EMAIL = os.environ.get('SENDER_EMAIL', 'me@gmail.com')24DAILY_LIMIT = 45025STATE_FILE = 'outreach_state.json'26LEADS_CSV = 'leads.csv'2728TEMPLATE_SUBJECT = 'Quick question for {company}'29TEMPLATE_BODY = ('Hi {first_name},\n\n'30 'I noticed {company} {observation}. Teams at this stage often struggle with {pain_point}.\n\n'31 'We help companies solve this in days, not months. Open to a quick 15-min chat?\n\nBest, Alex')3233def get_service():34 creds = None35 if os.path.exists('token.json'):36 creds = Credentials.from_authorized_user_file('token.json', SCOPES)37 if not creds or not creds.valid:38 if creds and creds.expired and creds.refresh_token:39 creds.refresh(Request())40 else:41 flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)42 creds = flow.run_local_server(port=0)43 with open('token.json', 'w') as f: f.write(creds.to_json())44 return build('gmail', 'v1', credentials=creds)4546def get_or_create_label(service, name):47 labels = service.users().labels().list(userId='me').execute().get('labels', [])48 for l in labels:49 if l['name'] == name: return l['id']50 r = service.users().labels().create(userId='me', body={'name': name}).execute()51 return r['id']5253def load_state():54 try:55 with open(STATE_FILE) as f: return json.load(f)56 except FileNotFoundError:57 return {'sent': {}, 'daily_counts': {}}5859def save_state(state):60 with open(STATE_FILE, 'w') as f: json.dump(state, f, indent=2)6162def encode_message(to, subject, body):63 msg = MIMEText(body, 'plain', 'utf-8')64 msg['From'] = SENDER_EMAIL65 msg['To'] = to66 msg['Subject'] = str(Header(subject, 'utf-8'))67 return {'raw': base64.urlsafe_b64encode(msg.as_bytes()).decode()}6869def check_replies(service, state):70 for email, s in state.get('sent', {}).items():71 if s.get('replied'): continue72 res = service.users().messages().list(userId='me', q=f'from:{email} in:anywhere', maxResults=1).execute()73 if res.get('messages'):74 state['sent'][email]['replied'] = True75 log.info(f'Reply from: {email}')76 time.sleep(0.1)7778def main():79 service = get_service()80 sent_label_id = get_or_create_label(service, 'Outreach/Sent')81 state = load_state()82 check_replies(service, state)83 save_state(state)84 today = str(date.today())85 today_count = state['daily_counts'].get(today, 0)86 with open(LEADS_CSV) as f:87 for lead in csv.DictReader(f):88 if today_count >= DAILY_LIMIT:89 log.warning('Daily limit reached')90 break91 email = lead['email']92 if email in state['sent']:93 continue94 try:95 subject = TEMPLATE_SUBJECT.format(**lead)96 body = TEMPLATE_BODY.format(**lead)97 msg = encode_message(email, subject, body)98 result = service.users().messages().send(userId='me', body=msg).execute()99 state['sent'][email] = {'message_id': result['id'], 'thread_id': result['threadId'], 'sent_at': today}100 service.users().messages().modify(userId='me', id=result['id'],101 body={'addLabelIds': [sent_label_id]}).execute()102 today_count += 1103 state['daily_counts'][today] = today_count104 log.info(f'Sent to {email} ({today_count}/{DAILY_LIMIT})')105 save_state(state)106 time.sleep(1.5)107 except HttpError as e:108 if '429' in str(e) or 'Daily' in str(e): break109 log.error(f'Failed {email}: {e}')110111if __name__ == '__main__':112 main()Error handling
User-rate limit exceeded (Mail sending)Hit the daily sending cap. For @gmail.com: 500 messages/day. For Workspace: 2,000 messages/day. This is a hard infrastructure limit.
Stop sending for the day. The limit resets at midnight. Build a daily counter and stop at 90% of the limit to leave buffer.
Wait until next calendar day — retrying immediately will not help.
Invalid base64 encoding for 'raw'Used standard base64 instead of base64url encoding. This is the most common error when first setting up the integration.
Use base64.urlsafe_b64encode() in Python. In JavaScript, replace + with - and / with _ in the base64 output.
No retry — fix encoding and resubmit.
Insufficient permissionsOAuth token missing gmail.send scope, or OAuth consent screen in Testing mode reached 100-user limit.
Re-run OAuth flow with correct SCOPES list. For >100 users, publish the consent screen (submit for verification if needed).
No retry — fix OAuth configuration.
Failed to parse 'From' headerThe From address in the message doesn't match or isn't a valid alias of the authenticated Gmail account.
Ensure From header matches the account's primary address or a verified alias in Gmail Settings → Accounts.
No retry — fix From header.
rateLimitExceededHit the per-minute quota limit (15,000 units/minute per user). Less common for outreach since messages.send at 100 units/call means max 150 sends/minute — well below daily limits.
Implement exponential backoff. Add 1.5s delay between sends to stay far below the per-minute limit.
Exponential backoff: 2^n seconds, max 64 seconds.
Rate Limits for Gmail API
| Scope | Limit | Window |
|---|---|---|
| Daily send cap (@gmail.com) | 500 messages | per day |
| Daily send cap (Workspace) | 2,000 messages | per user per day |
| Recipients per message (API) | 100 recipients | per message via API |
| Per-user quota | 15,000 quota units | per minute |
| messages.send cost | 100 quota units | per call |
1import time2import random3from googleapiclient.errors import HttpError45def send_with_retry(service, message_body, max_retries=4):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 code = e.resp.status13 if code in [500, 503]: # Transient errors only14 wait = min((2 ** attempt) + random.uniform(0, 1), 32)15 time.sleep(wait)16 elif code == 429 and 'Daily' in str(e):17 raise # Daily limit — don't retry18 elif code in [400, 401, 403]:19 raise # Auth/format errors — don't retry20 else:21 raise22 raise Exception('Max retries exceeded')- Track daily send count and stop at 90% of limit — leave buffer for manual emails sent from the same account
- Add 1-2 seconds between sends to stay well under per-minute quota limits
- For Workspace, each licensed user can send 2,000/day — distribute outreach across multiple accounts if you need more volume
- Never send the same message body verbatim to many recipients — Gmail's spam detection flags repetitive bulk patterns
- Use Google Workspace over personal @gmail.com for outreach — 4x higher daily limit (2,000 vs 500)
Security checklist
- Add credentials.json and token.json to .gitignore and never commit OAuth tokens
- Include a plain-text unsubscribe instruction in every outreach email to comply with CAN-SPAM
- Maintain a suppression list of opted-out and bounced addresses and check it before every send
- Never store lead data (names, emails, companies) in the same repository as your code — use separate secure storage
- The gmail.send scope requires OAuth verification before external users can authorize your app
- Rotate OAuth credentials if you suspect they've been exposed
- Log all sent message IDs with timestamps for CAN-SPAM compliance and bounce tracking
- Do not use the same Google account for automation sends and critical business communication — rate limit hits affect the entire account
Automation use cases
Cold Outreach Campaign
intermediateSend personalized initial contact emails to a prospect list with company-specific details, tracking state in a database for follow-ups.
Conference Follow-up
beginnerAfter an event, send personalized follow-up emails to people you met, referencing the specific conversation topic.
Partner Outreach at Scale
advancedReach out to potential integration partners or resellers with tailored pitches, using multiple Workspace accounts to stay within daily limits.
Reactivation Campaign
intermediateRe-engage dormant leads from your CRM with personalized messages referencing their last interaction date and product usage.
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 can trigger Gmail sends from CRM updates or spreadsheet additions, but the built-in personalization is limited without custom code.
- + No code required
- + Connects to 500+ CRM/data sources
- + Easy template setup
- - Limited merge field logic
- - Task limits on lower tiers
- - No built-in reply detection
Make (Integromat)
Free (1,000 ops/month); Core from $9/monthMake supports Gmail sends with rich data transformation for personalization, and can read from Google Sheets as a lead source.
- + Better data transformation
- + Google Sheets native integration
- + More affordable at scale
- - More complex setup
- - Still counts against Gmail daily limits
- - Free tier 1,000 ops/month
n8n
Free self-hosted; Cloud from €20/monthSelf-hosted n8n with Gmail node provides code-level control over personalization and state management without SaaS subscription costs.
- + Free self-hosted
- + Full control over send logic
- + Run on schedule via cron trigger
- - Requires server hosting
- - Gmail OAuth setup still needed
- - More technical configuration
Best practices
- Personalize beyond just {{name}} — reference the company's growth, recent news, or specific pain point to stand out
- Test your encoding with a single send to yourself before running the campaign against your full lead list
- Run your reply-check script at the start of each day BEFORE sending new messages — never follow up a replied lead
- Space your sends across multiple days rather than hitting the daily limit each day — steady cadence looks more natural to spam filters
- Use campaign state persistence (JSON file or database) — never rely on in-memory state that gets lost on crashes
- Respect opt-outs: include an unsubscribe mechanism and process removal requests within 10 business days (CAN-SPAM requirement)
- Monitor your Gmail Sent folder for messages marked as spam — this signals your sending patterns need adjustment
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 personalized outreach emails from a CSV lead list. I'm hitting the 429 rate limit after about 200 sends. My delay is 1 second between sends. But the error says 'Mail sending daily limit exceeded' not a quota error. How do I distinguish between the daily send cap and the per-minute quota limit, and what's the exact error response shape for each?
Build a Gmail sales outreach campaign manager. Features: 1) Upload CSV leads with name, company, email, pain_point fields, 2) Template editor with {{merge_field}} syntax preview, 3) Daily send scheduling with progress bar (X of 450 sent today), 4) Lead pipeline view: Pending / Sent / Replied / Opted Out, 5) One-click 'Mark as Replied' to suppress follow-ups. Use Supabase for leads/state storage and a Supabase Edge Function for Gmail API calls.
Frequently asked questions
How many emails can I send per day with the Gmail API?
500 messages per day for personal @gmail.com accounts. 2,000 messages per day per user for Google Workspace accounts (with a total of 10,000 recipients/day, max 100 recipients per message via API). These are hard infrastructure limits that cannot be raised by requesting a quota increase. Trial Workspace accounts are capped at 500/day.
Is the Gmail API suitable for cold email at scale?
No — not for large-scale cold outreach. Gmail's 500-2,000/day limits make it suitable for warm-lead follow-ups or small targeted campaigns. For bulk cold email at thousands/day, use a dedicated ESP (SendGrid, Mailchimp, Apollo) with proper deliverability infrastructure. The Gmail API is best for highly personalized outreach where the daily limit is not a constraint.
What happens when I hit the daily sending limit?
Gmail returns HTTP 429 with error message containing 'Daily sending quota exceeded' or 'Mail sending limit exceeded'. Subsequent send attempts that day will also fail. The limit resets at midnight in the account's timezone. Build a counter in your automation and stop at 90% of the limit to leave headroom for manual emails.
Can I send from multiple Gmail accounts to get higher daily volumes?
Technically yes — each account has its own 500/day (personal) or 2,000/day (Workspace) limit. However, coordinating multiple accounts requires separate OAuth tokens per account. Google Workspace allows this through Domain-Wide Delegation. Be cautious: IP-coordinated sending across many accounts can trigger Google's abuse detection.
Why are my outreach emails landing in spam?
Common causes: sending identical content to many recipients (vary your templates), no List-Unsubscribe header, your sending domain lacks SPF/DKIM/DMARC records, or you're sending to low-quality email lists. Use Gmail's sent rate (spread sends over the day), add proper email authentication for custom domain addresses, and ensure your lead list has been verified.
Is the Gmail API free for sales outreach?
The API itself is free. However, using the gmail.send scope for external apps requires completing Google's OAuth verification process. Google Workspace (needed for 2,000/day sending) starts at $6/user/month. There are no per-email API charges.
Can RapidDev help build a custom sales outreach system?
Yes. RapidDev has built 600+ apps including sales automation platforms with Gmail API integration, CRM connectivity, and reply detection. We can build the full pipeline: lead ingestion, personalization engine, send scheduling, reply tracking, and a management dashboard. Reach out at rapidevelopers.com for a free consultation.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation