Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Automate Email Warm-up (With a Verified List)

Warm-up is DNS, a slow ramp and clean addresses. Here is the scripted version, with real queries and the output you should expect.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

Automated email warm-up means a script or tool raises your daily send volume on a schedule, and every message goes to an address that will not bounce. LeadOcean does not send or warm mailboxes. It supplies the part that most warm-up guides skip: a list of verified addresses, released in the daily quota your ramp allows.

TL;DR

  • Setup takes about 45 minutes. The ramp then runs for 14 days with one script run a day.
  • The example ramp sends 695 messages and spends about 1,390 LeadOcean records: one per search row and one per enrichment. The 1,000 free records cover the first 12 days.
  • Sizing the list with a count call costs nothing. Only the daily batch pulls spend records.

Prerequisites

  • A new sending domain and mailbox, and access to its DNS.
  • A sender that lets you cap messages per day (your own SMTP, or any sequencer with a daily limit).
  • A LeadOcean API key, exported as $LEADOCEAN_API_KEY. Start free at app.leadocean.io, no card.
  • Python 3 with requests, and curl.

Why the list decides whether warm-up works

Warm-up tools raise volume. They do not fix a bad list. A new domain that sends 50 messages a day with a high bounce rate looks worse to mailbox providers than one that sends 5.

Two habits protect the domain. First, send only to addresses with email_status of verified. Second, stop the ramp when bounces rise, instead of letting the schedule run on.

LeadOcean refreshes the dataset monthly, and each record carries its own fetched_at date. Pull close to the send date and do not reuse an old export.

Step 1: Set SPF, DKIM and DMARC before any volume

Authentication comes first. Google's sender guidelines require SPF or DKIM from all senders, and SPF, DKIM and DMARC from bulk senders (source: support.google.com/a/answer/81126, October 2026). Set all three anyway. Add these three records at your DNS host.

text
; SPF: authorize your mail provider (replace the include with your provider's)
acme-outreach.com.            TXT  "v=spf1 include:_spf.mail-provider.example ~all"

; DKIM: the selector and key come from your mail provider
selector1._domainkey.acme-outreach.com.  TXT  "v=DKIM1; k=rsa; p=MIIB...placeholder"

; DMARC: start in monitor mode
_dmarc.acme-outreach.com.     TXT  "v=DMARC1; p=none; rua=mailto:dmarc@acme-outreach.com"

Check them with dig TXT _dmarc.acme-outreach.com +short before you send anything.

Run the check from a machine outside your mail provider. A DNS change can take a few hours to show up everywhere, so do this step the day before you start the ramp. Leave DMARC at p=none during warm-up and read the reports it sends. Tighten the policy only after the reports show your own mail passing.

Step 2: Script the ramp

A ramp is a list of daily caps. The numbers below are an example, not a provider rule. Raise them only while bounces stay near zero.

python
RAMP = [5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 100]

def cap_for(day: int) -> int:
    """day is 1-based; after the ramp, hold the last value."""
    return RAMP[min(day, len(RAMP)) - 1]

if __name__ == "__main__":
    print(sum(RAMP), "messages over", len(RAMP), "days")

Run it once to get the total.

bash
python3 ramp.py
695 messages over 14 days

That total sets your record budget. Each person costs two records: one for the search row and one for the enrichment that returns the address. So the full ramp needs about 1,390 records, more than the 1,000 on the free plan.

Step 3: Size a verified list for free

Count before you pull. A count call spends no records. This example is UK sales VPs with a verified email.

bash
curl -s -G "https://api.leadocean.io/v1/people/search" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  --data-urlencode "jobLevel=VP" \
  --data-urlencode "jobFunction=Sales & Business Development" \
  --data-urlencode "country=GB" \
  --data-urlencode "emailStatus=verified" \
  --data-urlencode "count=true" \
  --data-urlencode "limit=1"

count=true goes in the query string with limit=1, and the data array comes back empty. In an AI client the MCP tool leadocean_count_leads takes the same filters and is also free. Filter on emailStatus=verified rather than the default mix, because a warming domain should not touch catch-all addresses.

Step 4: Pull the list once, enrich one day's quota at a time

Search once for the whole ramp, then enrich only each day's cap. Do not store a search cursor between days: the API reference says a cursor is a position in one walk and should not be kept.

The first script pages through the search and saves the person_id of each row (meta.source_ids.person_id). It spends one record per row.

python
"""pull_list.py: run once."""
import os, json, requests
from ramp import RAMP

API = "https://api.leadocean.io/v1/people/search"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
body = {
    "jobLevel": ["VP"],
    "jobFunction": ["Sales & Business Development"],
    "country": ["GB"],
    "emailStatus": ["verified"],
}
need, ids, cursor = sum(RAMP), [], None
while len(ids) < need:
    page_body = dict(body, limit=min(need - len(ids), 100))
    if cursor:
        page_body["cursor"] = cursor
    r = requests.post(API, json=page_body, headers=H)
    r.raise_for_status()
    page = r.json()
    ids += [row["meta"]["source_ids"]["person_id"] for row in page["data"]]
    cursor = page["meta"].get("nextCursor")
    if not cursor:
        break
json.dump(ids, open("list.json", "w"))
print(len(ids), "people saved")

The daily script takes the next slice of IDs and enriches each one with reveal_email (POST /v1/people/enrich). Revealing the email costs no extra record, but each enrichment counts one.

python
"""daily_batch.py: run once a day."""
import os, json, csv, pathlib, requests
from ramp import cap_for

API = "https://api.leadocean.io/v1/people/enrich"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
STATE = pathlib.Path("state.json")
state = json.loads(STATE.read_text()) if STATE.exists() else {"day": 0, "done": 0}
state["day"] += 1
cap = cap_for(state["day"])
ids = json.load(open("list.json"))[state["done"]:state["done"] + cap]

rows = []
for pid in ids:
    r = requests.post(API, json={"person_id": pid, "reveal_email": True}, headers=H)
    if r.status_code == 404:
        continue
    r.raise_for_status()
    for e in r.json()["data"]["contact_data"]["contact_emails"]:
        if e.get("status") == "verified":
            rows.append({"person_id": pid, "email": e["email"]})
            break

state["done"] += len(ids)
STATE.write_text(json.dumps(state))
with open(f"day_{state['day']}.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["person_id", "email"])
    w.writeheader()
    w.writerows(rows)
print(f"day {state['day']}: cap {cap}, enriched {len(rows)}")

Schedule it with cron, one run a day before your send window.

text
0 6 * * *  cd /opt/warmup && python3 daily_batch.py

Load that day's CSV into your sender with the same cap. An enrichment that finds nobody is a 404 and costs nothing.

Step 5: Add a stop rule

A schedule that cannot stop is a risk. Put a guard in front of the daily pull. It reads yesterday's bounce count from your sender and halts the ramp when the rate crosses a line you choose.

python
import sys

def should_pause(sent: int, bounced: int, limit: float = 0.02) -> bool:
    """Pause the ramp if yesterday's bounce rate is above the limit."""
    return sent > 0 and bounced / sent > limit

if should_pause(sent=50, bounced=2):
    print("bounce rate 4.0%, ramp paused")
    sys.exit(1)

The 2% line is an example. Set your own, and hold the cap at the same level for a day or two instead of stepping up. Bounces do not come back as credit, so a paused day is cheaper than a burned one. Hold the ramp by not running daily_batch.py that day, so the day counter in state.json does not advance.

When you move from warm-up to real campaigns, size each audience with a count first. That keeps the first live sends as clean as the warm-up ones. The hosted MCP server gives the same free counts inside an AI client, so you can size a list in chat before you run the script.

Step 6: Move to live sending

Warm-up ends when you hold the cap at a level you plan to keep and real campaigns take over. Do not jump to full volume on day 15. Keep stepping up by the same small amounts for another week while you watch bounces and replies.

Build the live audience with the same filters, then add the ones warm-up skipped: seniority, industry and company size. Count each cut first. The count is free, so you can try ten variants and pull only the one you want.

bash
curl -s -G "https://api.leadocean.io/v1/people/search" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  --data-urlencode "jobLevel=VP" \
  --data-urlencode "country=GB" \
  --data-urlencode "employeeRange=51-200" \
  --data-urlencode "emailStatus=verified" \
  --data-urlencode "count=true" \
  --data-urlencode "limit=1"

For a list in the thousands, use a bulk CSV export instead of paging. Exports take up to 50,000 rows a request and 500,000 a day, and they are not on the free plan. The app at app.leadocean.io has an Exports page with a filter builder, a column picker and a price preview before you download.

Keep the warm-up mailboxes separate from your main domain. If one burns, you lose a spare, not the domain your team uses for customer mail.

What you get

The free count for the query in Step 3, run through the MCP tool leadocean_count_leads on 2026-10-21 (verified emails only, so this is a verified-email count, not everyone matching the other filters).

json
{
  "total": 918,
  "totalIsExact": true,
  "distinctCompanies": 710,
  "credits": 0,
  "unsupportedFilters": []
}

918 verified UK sales VPs is more than the 695 the ramp needs. The daily script prints one line per run.

text
day 1: cap 5, enriched 5
day 2: cap 10, enriched 10
day 14: cap 100, enriched 100

Response shape from the API reference for one search page, with placeholder values. Search rows carry no addresses; meta.total appears only when you send count=true.

json
{
  "success": true,
  "data": [
    {
      "profile_data": { "profile_full_name": "Jane Doe" },
      "contact_data": { "has_email": true, "email_status": "verified" },
      "meta": { "source_ids": { "person_id": "1234567890" } }
    }
  ],
  "meta": { "count": 5, "limit": 5, "nextCursor": "opaque-cursor-string", "credits": 5 }
}

Troubleshooting

These are the errors the API docs describe. Most warm-up runs hit the first three.

ErrorCauseFix
401Missing or wrong x-api-keyRe-export $LEADOCEAN_API_KEY and check the header name
429Above the plan's rate limit (5 requests a second on Free, 200 on Agency)Wait for Retry-After, then retry
402Out of records (Free is 1,000 for life)Shorten the ramp or move to Solo at $97 a month
Empty dataA filter value that is not an exact enum stringCheck values with leadocean_list_enum_values or GET /v1/enums
No nextCursorThe search paged to 10,000 rows (meta.depthCapped)Narrow the filters and run a second search
Export refusedOver 50,000 rows a request or 500,000 a daySplit the export into smaller requests

FAQ

Short answers to the questions that come up when people wire this into a sender.

Does LeadOcean warm up my mailbox?

No. LeadOcean is a data API with an MCP server. It has no sender, sequencer or inbox network. It gives your warm-up the verified addresses to send to.

Why only verified addresses during warm-up?

Bounces hurt a new domain fastest. LeadOcean's mailable default also includes catch-all addresses, so pass emailStatus=verified for warm-up. There is no refund for bounced emails, so read email_status before you send.

How long should the ramp run?

Two weeks is a common starting point, but the right length is the one where bounces and replies stay healthy. See how long to warm up email and the warm-up schedule tools for the options.

Which warm-up tools should I compare?

Our roundup of cold email warm-up tools covers the vendors. Pair whichever you pick with the list steps above.

What does it cost?

The free plan has 1,000 records for life, which covers the first 12 days of this 1,390-record ramp. Solo at $97 a month covers the rest, and Agency is $497 a month for teams. See pricing.

Warm the domain, then send to addresses that will not bounce

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →