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

How-to guide

How to Set Up Lead Routing in Your CRM (Rules, Round Robin, Import)

Ordered rules, a free headcount per segment, and about 40 lines of Python that assign every lead an owner before it reaches the CRM.

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

Lead routing works when ownership rules run in a fixed order and every lead lands on exactly one owner. This guide writes those rules, sizes each segment, assigns owners in code and ends with a CSV your CRM can import. For the definition, see what lead routing is.

TL;DR

  • You will write ordered routing rules, count each segment for free, then assign owners with a weighted round robin and import the file.
  • It takes about 40 minutes and spends about 100 LeadOcean records on the Free plan. Counts cost nothing.
  • The rules are CRM-neutral. Any CRM that imports an owner column can use the output.

Prerequisites

  • A LeadOcean API key in $LEADOCEAN_API_KEY. The Free plan works: 1,000 records for life, 5 requests a second.
  • Python 3.10+ with requests installed.
  • A list of reps per pool with a capacity weight each. Use placeholders until you are ready.
  • Admin rights to import records with an owner column into your CRM.

Step 1: Write the rules in order

Write rules as an ordered list and stop at the first match. Order matters more than the rules themselves.

yaml
rules:   # file: routing.yaml
  - name: noram-mid-market
    match: { salesRegion: [NORAM], employeeRange: ["51-200"] }
    pool: { jane@acme.com: 2, sam@acme.com: 1 }   # weight = share of leads
  - name: emea-mid-market
    match: { salesRegion: [EMEA], employeeRange: ["51-200"] }
    pool: { lena@acme.com: 1, omar@acme.com: 1 }
fallback:
  pool: { queue@acme.com: 1 }                      # a human triages these

Four habits keep this honest.

  • An existing account owner beats every rule below it. Two reps on one logo is the most common routing bug.
  • Territory beats size. A 100-person company in EMEA belongs to the EMEA pool, whatever its headcount.
  • Round robin only runs inside one pool, as the last step. It is a tiebreak, not a strategy.
  • Every lead that matches nothing goes to the fallback queue, never to nobody. Check that queue weekly, because a busy fallback means a missing rule.

Step 2: Size each segment for free

Count each rule before you load anything. A segment with 40 leads and three reps is a rule that needs merging. A segment with 58,000 leads and one rep is a rule that needs splitting. Send count=true as a query parameter with limit 1. Counts cost nothing.

bash
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true" \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{
    "salesRegion": ["NORAM"],
    "employeeRange": ["51-200"],
    "jobLevel": ["VP", "Director"],
    "jobFunction": ["Sales & Business Development"],
    "limit": 1
  }'

meta.total is capped at 100,000. If a segment hits the cap, add a filter such as country and split it into two rules. Filter values are exact strings, so check them with GET /v1/enums/sales_region first.

Step 3: Pull the leads for each rule

Pull one page per rule. The segment is known from the filter that returned the row, so you never guess it from a field. Filters are arrays, limit is 1 to 100 and paging uses cursor.

python
import os, time, requests, yaml

BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
COMMON = {"jobLevel": ["VP", "Director"],
          "jobFunction": ["Sales & Business Development"], "limit": 50}

cfg = yaml.safe_load(open("routing.yaml"))

def pull(match):
    r = requests.post(f"{BASE}/v1/people/search",
                      json={**COMMON, **match}, headers=HEADERS)
    r.raise_for_status()
    return r.json()["data"]

leads = []
for rule in cfg["rules"]:
    for person in pull(rule["match"]):
        leads.append((rule["name"], rule["pool"], person))
    time.sleep(0.25)  # stay under 5 requests a second on Free

Two pulls of 50 rows spend 100 records. Each row carries meta.source_ids.person_id, which is the key you will join on.

Step 4: Assign owners with a weighted round robin

Expand each pool by weight, then cycle through it. Jane with weight 2 gets two leads for every one that Sam gets.

python
import csv, itertools

cycles = {}
def next_owner(rule_name, pool):
    if rule_name not in cycles:
        order = [rep for rep, w in pool.items() for _ in range(w)]
        cycles[rule_name] = itertools.cycle(order)
    return next(cycles[rule_name])

with open("routed.csv", "w", newline="") as f:
    out = csv.writer(f)
    out.writerow(["person_id", "rule", "owner", "email_status"])
    for rule_name, pool, p in leads:
        pid = p["meta"]["source_ids"]["person_id"]
        status = p.get("contact_data", {}).get("email_status")
        out.writerow([pid, rule_name, next_owner(rule_name, pool), status])

A cycle per rule keeps the pools independent. Weights are static here. If you need capacity limits, skip any rep whose open-lead count in your CRM is above a ceiling you set. Do that check in next_owner, before the cycle returns.

Run the assignment on a schedule you can explain. A nightly batch is fine for purchased or exported lists. It is the wrong tool for a demo request, which needs an owner in minutes.

Step 5: Check the balance, then import

Count owners before you import. A skew of more than the weights predict means a bug, not bad luck.

bash
python3 - <<'PY'
import csv, collections
rows = list(csv.DictReader(open("routed.csv")))
print(collections.Counter((r["rule"], r["owner"]) for r in rows))
PY

Check two more things. Every row has an owner. No person_id appears twice, because a duplicate means two reps got the same buyer.

Then import routed.csv into your CRM. Map owner to the CRM's owner field and person_id to a custom text property. If your CRM's importer can leave existing records unchanged, turn that on, so a current account owner is never overwritten. If it cannot, drop rows that already exist before you import. That is rule one from Step 1.

Step 6: Review the rules every month

Routing drifts. Reps leave, territories change and a segment that was small grows. Rerun the Step 2 counts on the first of each month and compare them with last month's. The dataset is refreshed monthly, so counts move.

Keep a short log: the date, the count per rule and the owners. When a count grows by more than a third, split that rule. When a pool's leads sit untouched for a week, shrink the pool or merge the rule into a neighbour. The log takes two minutes and saves an argument about fairness later.

What you get

The free count for the NORAM rule on 21 October 2026, run through the leadocean_count_leads MCP tool with facets: false (VP or Director, Sales & Business Development, 51-200 employees):

json
{
  "total": 58289,
  "totalIsExact": true,
  "totalAll": 193276,
  "distinctCompanies": 29868,
  "credits": 0
}

The same filters with salesRegion: ["EMEA"] returned total 22,595, totalAll 111,179 and distinctCompanies 15,867 (21 October 2026, 0 records). total counts people with a mailable address (verified, catch_all_valid or catch_all). totalAll has no email condition.

That tells you what the rules are worth: NORAM holds 58,289 mailable leads across 29,868 companies, EMEA 22,595 across 15,867. Give NORAM roughly 2.5 times the rep capacity.

The routed file from Step 4 looks like this, with placeholder values.

csv
person_id,rule,owner,email_status
<person id>,noram-mid-market,jane@acme.com,verified
<person id>,noram-mid-market,jane@acme.com,verified
<person id>,noram-mid-market,sam@acme.com,catch_all
<person id>,emea-mid-market,lena@acme.com,verified

Each search row has this shape (response shape from the API reference, placeholder values).

json
{
  "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme" },
  "contact_data": { "has_email": true, "has_phone": false, "email_status": "verified" },
  "meta": { "source_ids": { "person_id": "<person id>" } }
}

Reading the output tells you where routing will break. Rows with catch_all or unknown status go to reps who should check before sending. Read email_status first, because bounces are not refunded.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported and the key is active in app.leadocean.io.
429Above the plan's rate limit (5 requests a second on Free, 200 on Agency)Honour Retry-After and keep the time.sleep in the loop.
402Records spent (1,000 for life on Free)Check GET /v1/usage, then move to a paid plan on pricing.
Empty dataA filter value is not an exact enum stringList valid values with GET /v1/enums/sales_region or leadocean_list_enum_values. Use NORAM, not North America.
meta.depthCapped is trueOne search pages to 10,000 rowsSplit the rule by country and run smaller searches.
Export refusedOver 50,000 rows a request or 500,000 a daySplit the pull by rule. Bulk CSV export is not on Free.

FAQ

What is the right order for lead routing rules?

Existing owner first, then territory, then segment, then round robin inside the pool, then a fallback queue. Each lead stops at its first match. Round robin is the last step, not the whole system.

Should I use round robin or weighted round robin?

Use weighted if reps differ in capacity, quota or ramp time. Plain round robin is the same thing with every weight set to 1. Start plain and add weights when a rep complains or idles.

How many records does routing cost?

Counting a segment is free. Pulling rows costs one record per person returned. Routing 100 leads costs 100 records, and an enrichment that finds nothing costs nothing.

Does this replace my CRM's routing?

No. It decides owners before import, which suits batch lists and outbound. For inbound forms that need an owner in minutes, keep native CRM routing and use the same rules there. Related: how to set up lead scoring in HubSpot and what a lead scrape is.

What if two rules match the same lead?

The first rule in the file wins. That is why Step 1 is an ordered list. Put the narrow rule above the broad one, and add a test lead per rule before you import.

How do I keep routing fair when a rep goes on leave?

Set that rep's weight to 0 in routing.yaml and rerun. Remove the rep from the expanded list rather than reassigning rows by hand. The cycle rebuilds on every run, so nothing is left to unwind.

Route leads you can actually email

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

Get your free API key →