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
bulkscope 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:
| Label | Type | Holds |
|---|---|---|
| EV Status | Single Line, or a Pick List with the five values | One of the five result statuses: valid, invalid, catch_all, unknown, disposable |
| EV Reason | Single Line | The reason 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:
- Open Setup → Developer Hub → Connections and create a connection to a custom service.
- Choose the authentication type API Key, with one parameter: key
Authorization, type Header. - Give the connection the link name
emailvalidation(or put yours intoconnectionNamein the settings). - Connect it, and enter
Bearerfollowed by your API key as the parameter's value.
3. Check records when their email changes
- Open Setup → Automation → Workflow Rules and create a rule for Contacts.
- 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.
- Add the instant action Function, create a new function, and paste the workflow function.
- Give the function two arguments:
recordId, set to the contact's Contact Id, andmoduleName, set to the valueContacts. - Save, then create a contact whose email is a test address, such as
accepted@sandbox.email-validation.org: within seconds EV Status showsvalid, 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.
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 ContactsorLeads(empty meansContacts)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,stoppedWhere 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 intorunsModulein the function.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.
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).
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 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, 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
unknownare 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.