# Validate Emails in Zoho CRM

URL: https://email-validation.org/docs/guides/zoho-crm/
Updated: 2026-11-06

> Documentation index: https://email-validation.org/llms.txt

Two Deluge functions check the email addresses in Zoho CRM with your API key and write the result into fields you
choose on Contacts or Leads:

- **On change:** a workflow rule checks a record's email when the record is created or its Email changes.
- **Clean-up:** a scheduled function works through a custom view (or search criteria) and checks every record that
  has no result yet for its current address, in a [bulk job](https://email-validation.org/docs/bulk/) when there are many.

This is a setup guide: you add the functions to your own Zoho CRM org from this page. There is no extension to
install from the Zoho Marketplace.

## Before you start

- **Zoho CRM edition.** Custom functions in workflow rules and schedules need an edition that offers them (in Zoho's
  current plans, Enterprise and higher, Zoho One and CRM Plus).
- **An API key for Zoho.** Create one on the [API keys page](https://email-validation.org/app/api-keys/). For the clean-up, turn on the `bulk`
  [scope](https://email-validation.org/docs/authentication/#scopes) too. Give it a [daily credit cap](https://email-validation.org/docs/authentication/#limits) so a busy
  workflow or a large view can never spend more than you expect.
- **Any Zoho data centre.** The functions use only Zoho's built-in CRM tasks, which run in your org's own data
  centre (zoho.com, zoho.eu, zoho.in, zoho.com.au, zoho.jp and the others), and call our API at the host you set.
  For [EU data residency](https://email-validation.org/docs/eu-data-residency/), for example with an org on zoho.eu, use `https://api-eu.email-validation.org`: the same key works on
  both hosts.

## 1. Add the result fields

In **Setup → Customization → Modules and Fields**, add these fields to Contacts (and to Leads, if you check leads
too). Use these labels, and Zoho gives them the API names the functions expect:

| Label | Type | Holds |
|---|---|---|
| EV Status | Single Line, or a Pick List with the five values | One of the five [result statuses](https://email-validation.org/docs/statuses/): `valid`, `invalid`, `catch_all`, `unknown`, `disposable` |
| EV Reason | Single Line | The [reason](https://email-validation.org/docs/statuses/) for the status, such as `accepted` or `no_mailbox` |
| EV Checked At | Date/Time | When the address was checked |
| EV Checked Email | Single Line | The address the result belongs to |

Other names work too: put your fields' API names (in **Setup → Developer Hub → APIs → API names**) into the settings
at the top of both functions. Leave `reasonField` or `checkedAtField` empty (`""`) to skip that field. *EV Checked
Email* is what keeps a record from being checked twice for the same address, so keep it.

## 2. Connect your API key

The key lives in a Zoho connection, never in the code:

1. Open **Setup → Developer Hub → Connections** and create a connection to a **custom service**.
2. Choose the authentication type **API Key**, with one parameter: key `Authorization`, type **Header**.
3. Give the connection the link name `emailvalidation` (or put yours into `connectionName` in the settings).
4. Connect it, and enter `Bearer ` followed by your API key as the parameter's value.

## 3. Check records when their email changes

1. Open **Setup → Automation → Workflow Rules** and create a rule for **Contacts**.
2. Run it on a record action: **Create or Edit**, and tick the option to repeat the rule whenever a record is
   edited, so every later change of Email is checked too. Where the rule offers it, limit edits to changes of the
   **Email** field. Either way, the function skips an address it has already checked, so other edits cost no
   credits.
3. Add the instant action **Function**, create a new function, and paste the [workflow function](#workflow-code).
4. Give the function two arguments: `recordId`, set to the contact's **Contact Id**, and `moduleName`, set to the
   value `Contacts`.
5. Save, then create a contact whose email is a [test address](https://email-validation.org/docs/test-addresses/), such as
   `accepted@sandbox.email-validation.org`: within seconds *EV Status* shows `valid`, and it costs nothing.

For Leads, make a second rule on Leads with the same function, `recordId` set to the **Lead Id** and `moduleName`
to `Leads`.

**It never loops.** The function writes its fields without running any workflow rule, and its write never touches
Email. If a rule fires again for the same address anyway, the function sees in *EV Checked Email* that the address is
already checked and stops, without a second check.

When a check cannot finish (the API is busy, the key has no credits left, or no answer arrived in time), the record
is left as it was, with no status. The clean-up below checks it later, and the function's log in Zoho names the
[error code](https://email-validation.org/docs/errors/). A check that got no answer is never sent again on its own, because it may already have
been counted.

### The workflow function

```text
// email-validation.org for Zoho CRM: checks a record's email when it is created or its Email changes.
// Workflow rule on Contacts or Leads: "On Create or Edit", only when the field Email is modified; action: this function.
// Arguments: recordId = the record's Id field, moduleName = "Contacts" or "Leads".
// The API key is never in this code: the connection "emailvalidation" sends it (see the guide).
void automation.ev_validate_on_change(String recordId, String moduleName)
{
	// ---- Settings: keep the same in both functions ----
	apiHost = "https://api.email-validation.org";
	// EU data residency: apiHost = "https://api-eu.email-validation.org";
	connectionName = "emailvalidation";
	statusField = "EV_Status";
	reasonField = "EV_Reason";
	checkedAtField = "EV_Checked_At";
	checkedEmailField = "EV_Checked_Email";
	// ---- End of settings ----
	statuses = {"valid","invalid","catch_all","unknown","disposable"};
	record = zoho.crm.getRecordById(moduleName, recordId.toLong());
	email = ifnull(record.get("Email"), "").trim();
	if(email == "")
	{
		info "no_email";
		return;
	}
	// Loop guard: the write below changes no Email and runs no workflow (trigger: empty list). If a rule fires again
	// anyway, the address is already checked: stop here, with no second check.
	if(checkedEmailField != "")
	{
		lastChecked = ifnull(record.get(checkedEmailField), "").trim().toLowerCase();
		if(lastChecked == email.toLowerCase() && ifnull(record.get(statusField), "") != "")
		{
			info "already_checked";
			return;
		}
	}
	payload = Map();
	payload.put("email", email);
	code = 0;
	answer = Map();
	try
	{
		response = invokeurl
		[
			url: apiHost + "/v1/validate"
			type: POST
			body: payload.toString()
			headers: {"Content-Type":"application/json","Accept":"application/json"}
			connection: connectionName
			detailed: true
		];
		code = ifnull(response.get("responseCode"), 0).toLong();
		answer = ifnull(response.get("responseText"), "").toString().toMap();
	}
	catch (e)
	{
		answer = Map();
	}
	status = "";
	if(code == 200)
	{
		status = ifnull(answer.get("status"), "");
	}
	if(!statuses.contains(status))
	{
		// No answer (not sent again: the check may have counted), busy, no credits, or a refused key: the record is
		// left as it was, so a scheduled clean-up run can check it later.
		errorCode = "connection_error";
		if(code != 0)
		{
			errorCode = "unexpected_response";
			if(answer.get("error") != null)
			{
				errorCode = ifnull(answer.get("error").get("code"), errorCode);
			}
		}
		info errorCode;
		return;
	}
	update = Map();
	update.put(statusField, status);
	if(reasonField != "")
	{
		update.put(reasonField, ifnull(answer.get("reason"), ""));
	}
	if(checkedAtField != "")
	{
		update.put(checkedAtField, zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss", "GMT") + "+00:00");
	}
	if(checkedEmailField != "")
	{
		update.put(checkedEmailField, email);
	}
	written = zoho.crm.updateRecord(moduleName, recordId.toLong(), update, {"trigger":List()});
	if(written.get("id") == null)
	{
		info "write_failed";
		return;
	}
	info status;
}
```

## 4. Clean up a whole view

The clean-up works from a list of *runs*: each run names the records to check. A schedule picks up the open runs and
works on them.

1. **The runs module.** In **Setup → Customization → Modules and Fields**, create a custom module with the plural
   name *EV Runs* and these fields:

   | Label | Type | Holds |
   |---|---|---|
   | EV Module | Single Line | `Contacts` or `Leads` (empty means `Contacts`) |
   | EV View ID | Single Line | The id of a custom view (the number at the end of the view's address in Zoho) |
   | EV Criteria | Single Line | Or search criteria instead of a view, such as `(Lead_Source:equals:Web)` |
   | EV Stage | Pick List: `queued` (default), `job`, `done`, `stopped` | Where the run stands |
   | EV Job ID | Single Line | The bulk job the run is waiting for |
   | EV Checked | Number | Records checked so far |
   | EV Note | Multi Line | What the last execution did, or what to fix |
   | EV Lock | Long Integer | Set while an execution works on the run |

   If Zoho gives the module another API name than `EV_Runs`, put it into `runsModule` in the function.
2. **A view of the records to check.** Create a custom view, for example *Contacts without a status*, with the
   criteria **EV Status is empty**. Records leave the view as they get a result, so a run works through any number
   of records.
3. **The schedule.** In **Setup → Automation → Schedules**, create a schedule that runs **hourly**, with a new
   function into which you paste the [clean-up function](#cleanup-code) (keep its settings the same as in the
   workflow function).
4. **Start a run.** Create a record in *EV Runs* with the view's id in *EV View ID*. The next scheduled execution
   picks it up.

Each execution reads up to 2,000 records of the view (Zoho's limit without paging tokens) and checks
those with no result for their current address:

- **Up to 50 records** are checked one by one and written at once.
- **More** go into one bulk job: the function uploads the record ids and addresses, saves the job's id on the run,
  and only then starts the job. The next execution reads the results and writes each record by its id, then takes
  the next records. A record whose address changed while the job ran gets no stale result: it is checked again with
  its new address.
- An execution starts no new work after 10 minutes, because Zoho stops a scheduled function after
  15, and the next one continues. With an hourly schedule, a large view moves by up to 2,000 records
  an hour. Do not add a second schedule that could overlap: the lock in *EV Lock* skips a run another execution holds,
  but one schedule is simpler.

**It never pays twice for one job.** The job is created with `start=false`, so it holds no credits until the function
starts it, and its id is saved on the run before that. An execution that is stopped halfway, or a start whose
answer got lost, is resumed with the same job: a second start of a started job is refused, never charged. A create
that got no answer is not sent again at once; if it did make a job, that job waits unstarted, costs nothing and
expires.

When the run is finished, *EV Stage* says `done`. To check records again later, clear their *EV Status* and create a
new run.

### The clean-up function

```text
// email-validation.org for Zoho CRM: the bulk clean-up. A schedule (hourly) runs this function; each record of the
// module EV Runs whose EV Stage is "queued" or "job" names a custom view (EV View ID) or search criteria (EV Criteria)
// of Contacts or Leads (EV Module). Each round reads up to 2,000 records and checks those without a status for their
// current address: up to 50 one by one, more in one bulk job. The job id is saved on the run before the job is
// started, so a run that stops halfway resumes the same job and never pays twice. Results are written by record id.
// The API key is never in this code: the connection "emailvalidation" sends it (see the guide).
void schedule.ev_bulk_step()
{
	// ---- Settings: keep the same in both functions ----
	apiHost = "https://api.email-validation.org";
	// EU data residency: apiHost = "https://api-eu.email-validation.org";
	connectionName = "emailvalidation";
	statusField = "EV_Status";
	reasonField = "EV_Reason";
	checkedAtField = "EV_Checked_At";
	checkedEmailField = "EV_Checked_Email";
	// ---- End of settings ----
	runsModule = "EV_Runs";
	pages = {1,2,3,4,5,6,7,8,9,10};
	perPage = 200;
	singlesUpTo = 50;
	budgetMs = 600000;
	lockMs = 960000;
	rounds = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20};
	polls = {1,2,3,4,5,6,7,8,9,10};
	statuses = {"valid","invalid","catch_all","unknown","disposable"};
	accountCodes = {"unauthorized","insufficient_credits","forbidden","key_daily_cap"};
	busyCodes = {"rate_limited","abuse_throttled","service_unavailable","upstream_capacity"};
	waitingStatuses = {"analyzing","queued","running","cancelling","finalizing"};
	startedAt = zoho.currenttime.toLong();
	runs = List();
	for each wanted in {"queued","job"}
	{
		found = zoho.crm.searchRecords(runsModule, "(EV_Stage:equals:" + wanted + ")");
		for each run in found
		{
			if(run.get("id") != null)
			{
				runs.add(run);
			}
		}
	}
	for each run in runs
	{
		if(zoho.currenttime.toLong() - startedAt > budgetMs)
		{
			break;
		}
		runId = run.get("id").toLong();
		if(ifnull(run.get("EV_Lock"), 0).toLong() > zoho.currenttime.toLong())
		{
			// Another execution is working on this run (a manual "Run now" while the schedule runs).
			continue;
		}
		locked = zoho.crm.updateRecord(runsModule, runId, {"EV_Lock":zoho.currenttime.toLong() + lockMs}, {"trigger":List()});
		if(locked.get("id") == null)
		{
			info "The run " + runId + " cannot be updated: check the EV Runs fields.";
			continue;
		}
		moduleName = ifnull(run.get("EV_Module"), "").trim();
		if(moduleName == "")
		{
			moduleName = "Contacts";
		}
		viewId = ifnull(run.get("EV_View_ID"), "").trim();
		criteria = ifnull(run.get("EV_Criteria"), "").trim();
		stage = run.get("EV_Stage");
		jobId = ifnull(run.get("EV_Job_ID"), "").trim();
		checked = ifnull(run.get("EV_Checked"), 0).toLong();
		note = "";
		createdHere = false;
		if(viewId == "" && criteria == "")
		{
			stage = "stopped";
			note = "Set EV View ID or EV Criteria, then set EV Stage back to queued.";
		}
		if(stage == "job" && jobId == "")
		{
			stage = "queued";
		}
		for each round in rounds
		{
			if(stage != "queued" && stage != "job")
			{
				break;
			}
			if(zoho.currenttime.toLong() - startedAt > budgetMs)
			{
				break;
			}
			// ---- 1. A saved bulk job: read it, start it once it is ready, or wait ----
			if(stage == "job")
			{
				jobStatus = "";
				jobError = "";
				for each poll in polls
				{
					code = 0;
					answer = Map();
					try
					{
						response = invokeurl
						[
							url: apiHost + "/v1/bulk/" + jobId
							type: GET
							headers: {"Accept":"application/json"}
							connection: connectionName
							detailed: true
						];
						code = ifnull(response.get("responseCode"), 0).toLong();
						answer = ifnull(response.get("responseText"), "").toString().toMap();
					}
					catch (e)
					{
						answer = Map();
					}
					if(code == 200)
					{
						jobStatus = ifnull(answer.get("status"), "");
					}
					else
					{
						jobError = "connection_error";
						if(code != 0)
						{
							jobError = "unexpected_response";
							if(answer.get("error") != null)
							{
								jobError = ifnull(answer.get("error").get("code"), jobError);
							}
						}
					}
					// A job created in this execution is analysed within seconds: ask again until it is ready.
					if(!(createdHere && jobStatus == "analyzing"))
					{
						break;
					}
				}
				if(jobStatus == "")
				{
					if(jobError == "not_found" || accountCodes.contains(jobError))
					{
						stage = "stopped";
						note = "Bulk job " + jobId + " could not be read (" + jobError + ").";
					}
					else
					{
						note = "Bulk job " + jobId + " could not be read (" + jobError + "): the next run tries again.";
					}
					break;
				}
				if(waitingStatuses.contains(jobStatus))
				{
					note = "Bulk job " + jobId + " is " + jobStatus + ": the next run continues.";
					break;
				}
				if(jobStatus == "ready")
				{
					// Starting holds the credits. A second start of a started job is refused (409), never charged again.
					code = 0;
					answer = Map();
					try
					{
						response = invokeurl
						[
							url: apiHost + "/v1/bulk/" + jobId + "/start"
							type: POST
							headers: {"Accept":"application/json"}
							connection: connectionName
							detailed: true
						];
						code = ifnull(response.get("responseCode"), 0).toLong();
						answer = ifnull(response.get("responseText"), "").toString().toMap();
					}
					catch (e)
					{
						answer = Map();
					}
					startError = "";
					if(code == 200 || code == 202)
					{
						startError = ifnull(answer.get("error_code"), "");
						if(ifnull(answer.get("status"), "") != "ready")
						{
							startError = "";
						}
					}
					else
					{
						startError = "connection_error";
						if(code != 0)
						{
							startError = "unexpected_response";
							if(answer.get("error") != null)
							{
								startError = ifnull(answer.get("error").get("code"), startError);
							}
						}
					}
					if(startError == "")
					{
						note = "Bulk job " + jobId + " started: " + ifnull(answer.get("credits_reserved"), 0) + " credits held.";
					}
					else if(accountCodes.contains(startError) || startError == "account_suspended" || startError == "billing_hold" || startError == "invalid_key" || startError == "forbidden_scope" || startError == "upload_expired")
					{
						stage = "stopped";
						note = "Bulk job " + jobId + " was not started (" + startError + "). Fix it, then set EV Stage back to job: the same job is started, never a second one.";
					}
					else
					{
						note = "Bulk job " + jobId + " was not started yet (" + startError + "): the next run tries again.";
					}
					break;
				}
				if(jobStatus != "completed")
				{
					stage = "stopped";
					note = "Bulk job " + jobId + " ended " + jobStatus + ". Set EV Stage back to queued to check these records with a new job.";
					break;
				}
			}
			// ---- 2. Read the records: their current addresses, and those still to check ----
			current = Map();
			toCheck = List();
			read = 0;
			skipped = 0;
			for each page in pages
			{
				if(viewId != "")
				{
					records = zoho.crm.getRecords(moduleName, page, perPage, {"cvid":viewId});
				}
				else
				{
					records = zoho.crm.searchRecords(moduleName, criteria, page, perPage);
				}
				count = 0;
				for each record in records
				{
					count = count + 1;
					if(record.get("id") == null)
					{
						continue;
					}
					recordId = record.get("id").toString();
					email = ifnull(record.get("Email"), "").trim();
					current.put(recordId, email);
					if(email == "")
					{
						continue;
					}
					if(email.contains(",") || email.contains("\""))
					{
						skipped = skipped + 1;
						continue;
					}
					if(ifnull(record.get(statusField), "") != "")
					{
						if(checkedEmailField == "")
						{
							continue;
						}
						if(ifnull(record.get(checkedEmailField), "").trim().toLowerCase() == email.toLowerCase())
						{
							continue;
						}
					}
					item = Map();
					item.put("id", recordId);
					item.put("email", email);
					toCheck.add(item);
				}
				read = read + count;
				if(count < perPage)
				{
					break;
				}
			}
			// ---- 3. A completed job: download its results and write them back by record id ----
			if(stage == "job")
			{
				code = 0;
				csv = "";
				answer = Map();
				try
				{
					response = invokeurl
					[
						url: apiHost + "/v1/bulk/" + jobId + "/results?filter=all&columns=original&escape_formulas=false"
						type: GET
						headers: {"Accept":"text/csv"}
						connection: connectionName
						detailed: true
					];
					code = ifnull(response.get("responseCode"), 0).toLong();
					csv = ifnull(response.get("responseText"), "").toString();
				}
				catch (e)
				{
					code = 0;
				}
				lines = csv.toList("\n");
				if(code != 200 || lines.size() == 0 || !lines.get(0).startsWith("id,email,ev_status,ev_reason,ev_action"))
				{
					note = "The results of bulk job " + jobId + " could not be read (HTTP " + code + "): the next run tries again.";
					break;
				}
				updates = List();
				written = 0;
				failed = 0;
				first = true;
				for each rawLine in lines
				{
					if(first)
					{
						first = false;
						continue;
					}
					line = rawLine.replaceAll("\r", "");
					cut = line.indexOf(",");
					if(cut <= 0)
					{
						continue;
					}
					recordId = line.subString(0, cut);
					line = line.subString(cut + 1, line.length());
					cut = line.indexOf(",");
					if(cut < 0)
					{
						continue;
					}
					uploaded = line.subString(0, cut);
					line = line.subString(cut + 1, line.length()) + ",";
					cut = line.indexOf(",");
					status = line.subString(0, cut);
					line = line.subString(cut + 1, line.length()) + ",";
					cut = line.indexOf(",");
					reason = line.subString(0, cut);
					if(!statuses.contains(status))
					{
						continue;
					}
					// Write only while the record still holds the address that was checked (the address column comes
					// back with a ' before a leading = + - or @, so apostrophes are left out of the comparison).
					now = current.get(recordId);
					if(now == null)
					{
						fetched = zoho.crm.getRecordById(moduleName, recordId.toLong());
						now = ifnull(fetched.get("Email"), "").trim();
					}
					if(now.replaceAll("'", "").toLowerCase() != uploaded.replaceAll("'", "").toLowerCase())
					{
						continue;
					}
					update = Map();
					update.put("id", recordId);
					update.put(statusField, status);
					if(reasonField != "")
					{
						update.put(reasonField, reason);
					}
					if(checkedAtField != "")
					{
						update.put(checkedAtField, zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss", "GMT") + "+00:00");
					}
					if(checkedEmailField != "")
					{
						update.put(checkedEmailField, now);
					}
					updates.add(update);
					if(updates.size() == 100)
					{
						result = zoho.crm.bulkUpdate(moduleName, updates, {"trigger":List()});
						if(result.toString().replaceAll(" ", "").contains("\"status\":\"error\""))
						{
							failed = failed + 1;
						}
						else
						{
							written = written + updates.size();
						}
						updates = List();
					}
				}
				if(updates.size() > 0)
				{
					result = zoho.crm.bulkUpdate(moduleName, updates, {"trigger":List()});
					if(result.toString().replaceAll(" ", "").contains("\"status\":\"error\""))
					{
						failed = failed + 1;
					}
					else
					{
						written = written + updates.size();
					}
				}
				checked = checked + written;
				stage = "queued";
				jobId = "";
				if(failed > 0)
				{
					stage = "stopped";
					note = "Some records could not be written (" + failed + " batches of up to 100). Fix them, then set EV Stage back to queued.";
					break;
				}
				if(written == 0)
				{
					stage = "stopped";
					note = "No result could be written: every record changed its address while the job ran. Set EV Stage back to queued to check them again.";
					break;
				}
				zoho.crm.updateRecord(runsModule, runId, {"EV_Stage":stage,"EV_Job_ID":"","EV_Checked":checked}, {"trigger":List()});
				continue;
			}
			// ---- 4. Nothing left, a few records one by one, or one bulk job ----
			if(toCheck.size() == 0)
			{
				if(read >= pages.size() * perPage)
				{
					stage = "stopped";
					note = "The first 2,000 records are checked, but the view holds more: filter it to records whose status is empty, then set EV Stage back to queued.";
				}
				else
				{
					stage = "done";
					note = "Done: " + checked + " records checked.";
				}
				if(skipped > 0)
				{
					note = note + " " + skipped + " records were left out: their Email holds a comma or a quote.";
				}
				break;
			}
			if(toCheck.size() <= singlesUpTo)
			{
				written = 0;
				failed = 0;
				stopCode = "";
				lastError = "";
				for each item in toCheck
				{
					if(zoho.currenttime.toLong() - startedAt > budgetMs)
					{
						break;
					}
					payload = Map();
					payload.put("email", item.get("email"));
					code = 0;
					answer = Map();
					try
					{
						response = invokeurl
						[
							url: apiHost + "/v1/validate"
							type: POST
							body: payload.toString()
							headers: {"Content-Type":"application/json","Accept":"application/json"}
							connection: connectionName
							detailed: true
						];
						code = ifnull(response.get("responseCode"), 0).toLong();
						answer = ifnull(response.get("responseText"), "").toString().toMap();
					}
					catch (e)
					{
						answer = Map();
					}
					status = "";
					if(code == 200)
					{
						status = ifnull(answer.get("status"), "");
					}
					if(statuses.contains(status))
					{
						update = Map();
						update.put(statusField, status);
						if(reasonField != "")
						{
							update.put(reasonField, ifnull(answer.get("reason"), ""));
						}
						if(checkedAtField != "")
						{
							update.put(checkedAtField, zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss", "GMT") + "+00:00");
						}
						if(checkedEmailField != "")
						{
							update.put(checkedEmailField, item.get("email"));
						}
						result = zoho.crm.updateRecord(moduleName, item.get("id").toLong(), update, {"trigger":List()});
						if(result.get("id") == null)
						{
							failed = failed + 1;
						}
						else
						{
							written = written + 1;
						}
						continue;
					}
					errorCode = "connection_error";
					if(code != 0)
					{
						errorCode = "unexpected_response";
						if(answer.get("error") != null)
						{
							errorCode = ifnull(answer.get("error").get("code"), errorCode);
						}
					}
					if(accountCodes.contains(errorCode) || busyCodes.contains(errorCode))
					{
						stopCode = errorCode;
						break;
					}
					if((code == 429 || code == 503) && answer.get("error") == null)
					{
						stopCode = "service_unavailable";
						break;
					}
					// No answer is not sent again now (the check may have counted): a later round checks it again.
					lastError = errorCode;
				}
				checked = checked + written;
				if(accountCodes.contains(stopCode))
				{
					stage = "stopped";
					note = "Stopped (" + stopCode + "). Fix it, then set EV Stage back to queued.";
					break;
				}
				if(stopCode != "")
				{
					note = "The API was busy (" + stopCode + "): the next run continues.";
					break;
				}
				if(failed > 0)
				{
					stage = "stopped";
					note = failed + " records could not be written. Fix them, then set EV Stage back to queued.";
					break;
				}
				if(written == 0)
				{
					if(zoho.currenttime.toLong() - startedAt <= budgetMs)
					{
						stage = "stopped";
						note = "No record could be checked (" + lastError + "). Set EV Stage back to queued to try again.";
					}
					break;
				}
				continue;
			}
			csv = "id,email\n";
			for each item in toCheck
			{
				csv = csv + item.get("id") + "," + item.get("email") + "\n";
			}
			code = 0;
			answer = Map();
			try
			{
				response = invokeurl
				[
					url: apiHost + "/v1/bulk?column=email&has_header=true&start=false"
					type: POST
					body: csv
					headers: {"Content-Type":"text/csv","Accept":"application/json"}
					connection: connectionName
					detailed: true
				];
				code = ifnull(response.get("responseCode"), 0).toLong();
				answer = ifnull(response.get("responseText"), "").toString().toMap();
			}
			catch (e)
			{
				answer = Map();
			}
			if((code == 200 || code == 202) && ifnull(answer.get("id"), "").startsWith("job_"))
			{
				// Saved before anything else: from here on this run only ever starts this job.
				jobId = answer.get("id");
				stage = "job";
				createdHere = true;
				saved = zoho.crm.updateRecord(runsModule, runId, {"EV_Stage":"job","EV_Job_ID":jobId}, {"trigger":List()});
				if(saved.get("id") == null)
				{
					// Unsaved, the job is never started: it holds no credits and expires.
					stage = "stopped";
					note = "Bulk job " + jobId + " could not be saved on this run, so it was not started. Check the EV Runs fields.";
					jobId = "";
					break;
				}
				continue;
			}
			// A create without an answer is not sent again now: a job it may have made waits unstarted, holding no
			// credits, and expires.
			errorCode = "connection_error";
			if(code != 0)
			{
				errorCode = "unexpected_response";
				if(answer.get("error") != null)
				{
					errorCode = ifnull(answer.get("error").get("code"), errorCode);
				}
			}
			if(accountCodes.contains(errorCode) || errorCode == "invalid_request" || errorCode == "payload_too_large")
			{
				stage = "stopped";
				note = "The bulk job could not be created (" + errorCode + "). Fix it, then set EV Stage back to queued.";
			}
			else
			{
				note = "The bulk job could not be created yet (" + errorCode + "): the next run tries again.";
			}
			break;
		}
		save = Map();
		save.put("EV_Stage", stage);
		save.put("EV_Job_ID", jobId);
		save.put("EV_Checked", checked);
		save.put("EV_Note", note);
		save.put("EV_Lock", 0);
		zoho.crm.updateRecord(runsModule, runId, save, {"trigger":List()});
	}
}
```

## Run stages and notes

| EV Stage | What it means | What to do |
|---|---|---|
| `queued` | Waiting for the next execution, or between rounds | Nothing |
| `job` | A bulk job is analysed, running or ready to be read | Nothing: the next execution continues it |
| `done` | Every record in the view has a result | Nothing |
| `stopped` | The run needs you; *EV Note* says why | Fix it, then set *EV Stage* back to the value the note names |

Some notes a run can stop with:

| Note names | What to do |
|---|---|
| `unauthorized` | Check the key in the connection; create a new key if it was revoked |
| `forbidden` | Turn on the `bulk` scope for the key, or use a view with at most 50 records to check |
| `insufficient_credits` | [Buy a pack](https://email-validation.org/pricing/), then set *EV Stage* back: a job waiting to start is started, not created again |
| `key_daily_cap` | Set *EV Stage* back the next day, or raise the key's cap |
| "the view holds more" | The view keeps records that already have a result: filter it to **EV Status is empty** |
| "could not be written" | Zoho refused some writes (a validation rule or a required field): fix those records, then set *EV Stage* back to `queued` |

A busy API (`rate_limited`, `service_unavailable` and the like) never stops a run: the next execution continues.

## Costs and limits

- Each check is one credit, one by one or in a bulk job. Results with the status `unknown` are free, and so are the
  [test addresses](https://email-validation.org/docs/test-addresses/). In a bulk job, duplicates and malformed addresses are free too.
- A record with a result for its current address is never checked again by a run.
- Zoho counts each task the functions run (reading pages of records, writing results, calling our API) toward your
  org's daily function and API limits; a bulk round writes its results in batches of 100.
- A workflow function may run for 30 seconds in Zoho, which leaves room for the longest check.
- For a one-time list, you can also export it from Zoho, upload it as a [bulk job](https://email-validation.org/docs/bulk/) in the dashboard,
  and import the results.
