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, andcurl.
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.
; 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.
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.
python3 ramp.py
695 messages over 14 daysThat 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.
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.
"""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.
"""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.
0 6 * * * cd /opt/warmup && python3 daily_batch.pyLoad 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.
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.
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).
{
"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.
day 1: cap 5, enriched 5
day 2: cap 10, enriched 10
day 14: cap 100, enriched 100Response 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.
{
"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.
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Re-export $LEADOCEAN_API_KEY and check the header name |
| 429 | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Wait for Retry-After, then retry |
| 402 | Out of records (Free is 1,000 for life) | Shorten the ramp or move to Solo at $97 a month |
Empty data | A filter value that is not an exact enum string | Check values with leadocean_list_enum_values or GET /v1/enums |
No nextCursor | The search paged to 10,000 rows (meta.depthCapped) | Narrow the filters and run a second search |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split 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 →