Validate emails in Zoho CRM

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 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. For the clean-up, turn on the bulk scope too. Give it a daily credit cap 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, 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:

LabelTypeHolds
EV StatusSingle Line, or a Pick List with the five valuesOne of the five result statuses: valid, invalid, catch_all, unknown, disposable
EV ReasonSingle LineThe reason for the status, such as accepted or no_mailbox
EV Checked AtDate/TimeWhen the address was checked
EV Checked EmailSingle LineThe 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.
  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, 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. A check that got no answer is never sent again on its own, because it may already have been counted.

The workflow function

// 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:

    LabelTypeHolds
    EV ModuleSingle LineContacts or Leads (empty means Contacts)
    EV View IDSingle LineThe id of a custom view (the number at the end of the view's address in Zoho)
    EV CriteriaSingle LineOr search criteria instead of a view, such as (Lead_Source:equals:Web)
    EV StagePick List: queued (default), job, done, stoppedWhere the run stands
    EV Job IDSingle LineThe bulk job the run is waiting for
    EV CheckedNumberRecords checked so far
    EV NoteMulti LineWhat the last execution did, or what to fix
    EV LockLong IntegerSet 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 (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

// 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 StageWhat it meansWhat to do
queuedWaiting for the next execution, or between roundsNothing
jobA bulk job is analysed, running or ready to be readNothing: the next execution continues it
doneEvery record in the view has a resultNothing
stoppedThe run needs you; EV Note says whyFix it, then set EV Stage back to the value the note names

Some notes a run can stop with:

Note namesWhat to do
unauthorizedCheck the key in the connection; create a new key if it was revoked
forbiddenTurn on the bulk scope for the key, or use a view with at most 50 records to check
insufficient_creditsBuy a pack, then set EV Stage back: a job waiting to start is started, not created again
key_daily_capSet 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. 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 in the dashboard, and import the results.

Updated · Maintained by the email-validation.org team