My organization works with mostly educational institutions. We have a custom module called "Schools", which is the user-entered school name they put when using our service (which they enter along with their state and zip code). We want to map this to a list of known schools from a public database that has both a standardized school name, address, phone number, and other data about the school (along with a unique identifier) stored in our org's Zoho Analytics database.
To accomplish this, we are trying to leverage OpenAI's API to find a "best match" of the unique ID. When a school is created in our system, I am getting the "State", "Zip", and entered school name. This runs a query in Zoho Analytics to return batches of 250 schools in that state at a time (to avoid API size problems), and runs this through OpenAI to find which in each batch is the closest match to what was entered, and saves that to a new array. This repeats for all schools in the state, then afterwards runs the set of "winners" through OpenAI one more time to get the best overall winner.
{"error":{"message":"We could not parse the JSON body of your request. (HINT: This likely means you aren't using your HTTP library correctly. The OpenAI API expects a JSON payload, but what was sent was not valid JSON. If you have trouble figuring out how to fix this, please contact us through our help center at help.openai.com.)","type":"invalid_request_error","param":null,"code":null}}
I verified several times that my JSON body is indeed correct. I also have alternated between using "parameters" and "body", and various ways to stringify the payload - none of which have any improvement. Recommendations from ChatGPT believe that regardless of me specifying "content-type: application/json", it is still mangling the payload.
The worst part is - this actually worked just fine until last night (October 17, 2025), sometime around 5pm. I had been running the version of the script below to back-fill our existing school database, which had been calling this API every 45 seconds for a couple days without any errors. OpenAI insists they have not changed anything on their end, and zoho insists the same on theirs. Any help would be appreciated.
string standalone.SetSchoolAddressAPI()
{
PAGE_SIZE = 250;
random = randomNumber(1,50);
school = zoho.crm.searchRecords("Schools","(NCES_School_ID:equals:99)",1,random);
st_school_id = school.get(0).get("id");
info st_school_id;
school = zoho.crm.getRecordById("Schools",st_school_id);
school_name = ifnull(school.get("Name"),"");
acc_id = null;
if(school.containsKey("Account") && school.get("Account") != null && school.get("Account").containsKey("id"))
{
	acc_id = school.get("Account").get("id");
}
account = Map();
if(acc_id != null)
{
	account = zoho.crm.getRecordById("Accounts",acc_id);
}
account = ifnull(account,Map());
school_zip = ifnull(school.get("Zip"),account.get("Billing_Code"));
school_state = ifnull(school.get("State"),account.get("Billing_State"));
target_name = ifnull(school_name,"");
state_input = ifnull(school_state,"");
zip_input = ifnull(school_zip,"");
/***** --- Enforce state presence & normalize --- *****/
if(state_input == null || state_input == "")
{
	update_map = Map();
	update_map.put("NCES_School_ID",null);
	update_map.put("Country","");
	update_map.put("State",school_state);
	zoho.crm.updateRecord("Schools",st_school_id,update_map);
	return "No state on record; aborting NCES match.";
}
state_input = state_input.trim();
/***** === Zoho Analytics fixed config (unchanged) === *****/
workspace_name = "Zoho CRM Analytics";
view_name = "NCES Schools";
owner_email = "xxx";
ws_enc = zoho.encryption.urlEncode(workspace_name);
view_enc = zoho.encryption.urlEncode(view_name);
endpoint = "https://analyticsapi.zoho.com/api/" + owner_email + "/" + ws_enc + "/" + view_enc;
/***** === Step 1: Count rows by state === *****/
state_esc = state_input.replaceAll("'","''");
count_sql = "select count(*) as total_count from \"" + view_name + "\" where \"State\"='" + state_esc + "'";
count_params = Map();
count_params.put("ZOHO_ACTION","EXPORT");
count_params.put("ZOHO_OUTPUT_FORMAT","JSON");
count_params.put("ZOHO_SQLQUERY",count_sql);
count_params.put("ZOHO_API_VERSION","1.0");
count_resp = invokeurl
[
	url :endpoint
	type :POST
	parameters:count_params
	connection:"zoho_analytics_conn"
];
total_count = 0;
// First try KV shape: {"data":[{"total_count":"10398"}]}
if(count_resp != null && count_resp.containsKey("data") && count_resp.get("data") != null && count_resp.get("data").size() > 0)
{
	tc_raw = count_resp.get("data").get(0).get("total_count");
	if(tc_raw != null)
	{
		total_count = tc_raw.toLong();
	}
}
else
{
	// Fallback to rows shape:
	// {"response":{"result":{"column_order":["total_count"],"rows":[["10398"]]}}}
	if(count_resp != null && count_resp.containsKey("response"))
	{
		resp_obj = count_resp.get("response");
		if(resp_obj != null && resp_obj.containsKey("result"))
		{
			result_obj = resp_obj.get("result");
			if(result_obj != null && result_obj.containsKey("rows"))
			{
				rows_list = result_obj.get("rows");
				if(rows_list != null && rows_list.size() > 0)
				{
					first_row = rows_list.get(0);
					// rows are arrays, so first cell holds the count as string
					if(first_row != null && first_row.size() > 0)
					{
						tc_raw2 = first_row.get(0);
						if(tc_raw2 != null)
						{
							total_count = tc_raw2.toLong();
						}
					}
				}
			}
		}
	}
}
/***** === Step 2: Build list of page indexes (Deluge compliant, no while) === *****/
page_map = list();
num_pages = (total_count / PAGE_SIZE).toLong();
if(total_count % PAGE_SIZE > 0)
{
	num_pages = num_pages + 1;
}
// use nested for-each loops over a fixed dummy list to reach num_pages
dummy = list();
for each  d in {"a","b","c","d","e","f","g","h","i","j"}
{
	// outer loop (10×)
	for each  e in {"1","2","3","4","5","6","7","8","9","10"}
	{
		// inner loop (10×)
		if(page_map.size() < num_pages)
		{
			page_map.add(page_map.size());
		}
	}
}
/***** === Step 3: For each page, fetch candidates and pick a batch winner via OpenAI === *****/
batch_winners = list();
for each  p in page_map
{
	offset_val = p * PAGE_SIZE;
	sql_batch = "select \"State\",\"School Name\",\"NCES School ID\",\"Zip\"" + "from \"" + view_name + "\" where \"State\"='" + state_esc + "' " + "limit " + PAGE_SIZE.toString() + " offset " + offset_val.toString();
	params = Map();
	params.put("ZOHO_ACTION","EXPORT");
	params.put("ZOHO_OUTPUT_FORMAT","JSON");
	params.put("ZOHO_API_VERSION","1.0");
	params.put("KEY_VALUE_FORMAT","true");
	params.put("ZOHO_SQLQUERY",sql_batch);
	count_params.put("ZOHO_API_VERSION","1.0");
	resp_batch = invokeurl
	[
		url :endpoint
		type :POST
		parameters:params
		connection:"zoho_analytics_conn"
	];
	/***** Parse candidates safely no matter the response shape *****/
	candidates = list();
	if(resp_batch != null)
	{
		// Case 1: Key-value format (normal Zoho Analytics JSON)
		if(resp_batch.containsKey("data") && resp_batch.get("data") != null)
		{
			candidates = resp_batch.get("data");
		}
		// Case 2: Nested "response" > "result" > "rows" format
		else if(resp_batch.containsKey("response"))
		{
			resp_obj = resp_batch.get("response");
			if(resp_obj != null && resp_obj.containsKey("result"))
			{
				result_obj = resp_obj.get("result");
				if(result_obj != null && result_obj.containsKey("rows"))
				{
					rows_list = result_obj.get("rows");
					// convert matrix rows into list of maps for consistency
					for each  row_item in rows_list
					{
						row_map = Map();
						// optional: if you have column_order, map by index
						if(result_obj.containsKey("column_order"))
						{
							cols = result_obj.get("column_order");
							idx = 0;
							for each  col in cols
							{
								if(row_item.size() > idx)
								{
									row_map.put(col,row_item.get(idx));
								}
								idx = idx + 1;
							}
						}
						candidates.add(row_map);
					}
				}
			}
		}
	}
	if(candidates.isEmpty())
	{
		info "No candidates found for this page (state=" + state_input + ")";
		continue;
	}
	/***** Build compact OpenAI prompt for this batch *****/
	cand_lines = "";
	for each  c in candidates
	{
		id_short = ifnull(c.get("NCES School ID"),"");
		name_short = ifnull(c.get("School Name"),"");
		if(name_short != null && name_short.length() > 60)
		{
			name_short = name_short.substring(0,60);
		}
		zip_short = ifnull(c.get("Zip"),"");
		if(zip_short != null && zip_short.length() > 10)
		{
			zip_short = zip_short.substring(0,10);
		}
		delimiter = "###";
		// define this once before the loop if not already defined
		record_sep = " || ";
		// separates candidate rows, readable and single-line
		cand_lines = cand_lines + id_short + "|" + name_short + "|" + zip_short + record_sep;
	}
	// === Build simplified OpenAI request (no line breaks) ===
	prompt = "TARGET=" + target_name + "|" + zip_input + delimiter + "CANDIDATES=" + cand_lines + delimiter + "Return ONLY JSON {\"nces_school_id\": string|null, \"confidence\": number}";
	messages = List();
	messages.add({"role":"system","content":"Match the user-entered school to the best NCES candidate using NAME similarity and ZIP proximity. The delimiter is " + delimiter + "."});
	messages.add({"role":"user","content":prompt});
	req_body = Map();
	req_body.put("model","gpt-4o-mini");
	req_body.put("temperature",0);
	req_body.put("messages",messages);
	ai_resp = invokeurl
	[
		url :"https://api.openai.com/v1/chat/completions"
		type :POST
		parameters:req_body.toString()
		connection:"openai_custom"
	];
	// ========== Step 4: Inspect result ==========
	best_id = "";
	best_conf = 0.0;
	raw_content = ai_resp.get("choices").get(0).get("message").get("content");
	// Remove markdown fences if present
	cleaned = raw_content.replaceAll("(?s)```json|```","").trim();
	parsed = cleaned.toMap();
	school_id = parsed.get("nces_school_id");
	confidence = parsed.get("confidence");
	for each  c in candidates
	{
		if(c.get("NCES School ID") == school_id)
		{
			if(confidence > 0.8)
			{
				//do not put low-confidence candidates in the winner batch
				c.put("confidence",confidence);
				batch_winners.add(c);
				break;
			}
		}
	}
}
/***** === Step 4: Final round — pick overall best among batch winners === *****/
if(batch_winners.size() == 0)
{
	update_map = Map();
	update_map.put("NCES_School_ID",null);
	update_map.put("Country","");
	update_map.put("State",school_state);
	zoho.crm.updateRecord("Schools",st_school_id,update_map);
	info "No candidates found for state=" + state_input;
}
winner_lines = "";
for each  w in batch_winners
{
	id_short = ifnull(w.get("NCES School ID"),"");
	name_short = ifnull(w.get("School Name"),"");
	if(name_short != null && name_short.length() > 60)
	{
		name_short = name_short.substring(0,60);
	}
	zip_short = ifnull(w.get("Zip"),"");
	if(zip_short != null && zip_short.length() > 10)
	{
		zip_short = zip_short.substring(0,10);
	}
	delimiter = "###";
	// define this once before the loop if not already defined
	record_sep = " || ";
	// separates candidate rows, readable and single-line
	winner_lines = winner_lines + id_short + "|" + name_short + "|" + zip_short + record_sep;
}
// === Build simplified OpenAI request (no line breaks) ===
prompt = "TARGET=" + target_name + "|" + zip_input + delimiter + "CANDIDATES=" + winner_lines + delimiter + "Return ONLY JSON {\"nces_school_id\": string|null, \"confidence\": number}";
messages = List();
messages.add({"role":"system","content":"Match the user-entered school to the best NCES candidate using NAME similarity and ZIP proximity. The delimiter is " + delimiter + "."});
messages.add({"role":"user","content":prompt});
req_body = Map();
req_body.put("model","gpt-4o-mini");
req_body.put("temperature",0);
req_body.put("messages",messages);
final_ai = invokeurl
[
	url :"https://api.openai.com/v1/chat/completions"
	type :POST
	parameters:req_body.toString()
	connection:"openai_custom"
];
selected_nces = "";
confidence = 0.0;
contentText = final_ai.get("choices").get(0).get("message").get("content");
// Step 2: Sanitize it — remove code block markers and trim spaces/newlines
cleanText = contentText.replaceAll("```json","");
cleanText = cleanText.replaceAll("```","");
cleanText = cleanText.trim();
// Step 3: Parse the cleaned string as JSON
contentJSON = cleanText.toMap();
// Step 4: Extract the NCES/NCAS ID
selected_nces = contentJSON.get("nces_school_id");
/***** === Step 5: Find selected record and update CRM (unchanged) === *****/
/***** === Step 2: Fetch single row by NCES School ID === *****/
row_sql = "select \"NCES School ID\", \"Website\", \"Address\", \"Address 2\", \"City\", \"State\", \"Zip\", \"Phone\", \"Grade Level\" " + "from \"" + view_name + "\" " + "where \"NCES School ID\" = '" + selected_nces + "' " + "limit 1";
row_params = Map();
row_params.put("ZOHO_ACTION","EXPORT");
row_params.put("ZOHO_OUTPUT_FORMAT","JSON");
row_params.put("ZOHO_SQLQUERY",row_sql);
row_params.put("ZOHO_API_VERSION","1.0");
row_resp = invokeurl
[
	url :endpoint
	type :POST
	parameters:row_params
	connection:"zoho_analytics_conn"
];
/***** === Step 3: Parse response and assign variables === *****/
nces_id_final = "";
Website_final = "";
Address_final = "";
Address2_final = "";
City_final = "";
State_final = "";
Zip_final = "";
Phone_final = "";
Grade_final = "";
// First check for JSON KV structure
if(row_resp != null && row_resp.containsKey("data") && row_resp.get("data") != null && row_resp.get("data").size() > 0)
{
	row_data = row_resp.get("data").get(0);
	nces_id_final = selected_nces;
	Website_final = ifnull(row_data.get("Website"),"");
	Address_final = ifnull(row_data.get("Address"),"");
	Address2_final = ifnull(row_data.get("Address 2"),"");
	City_final = ifnull(row_data.get("City"),"");
	State_final = ifnull(row_data.get("State"),"");
	Zip_final = ifnull(row_data.get("Zip"),"");
	Phone_final = ifnull(row_data.get("Phone"),"");
	Grade_final = ifnull(row_data.get("Grade Level"),"");
}
else
{
	// Fallback for "rows" format
	if(row_resp != null && row_resp.containsKey("response"))
	{
		resp_obj = row_resp.get("response");
		if(resp_obj != null && resp_obj.containsKey("result"))
		{
			result_obj = resp_obj.get("result");
			if(result_obj != null && result_obj.containsKey("rows"))
			{
				rows_list = result_obj.get("rows");
				if(rows_list != null && rows_list.size() > 0)
				{
					first_row = rows_list.get(0);
					if(first_row != null && first_row.size() >= 9)
					{
						nces_id_final = ifnull(first_row.get(0),"");
						Website_final = ifnull(first_row.get(1),"");
						Address_final = ifnull(first_row.get(2),"");
						Address2_final = ifnull(first_row.get(3),"");
						City_final = ifnull(first_row.get(4),"");
						State_final = ifnull(first_row.get(5),"");
						Zip_final = ifnull(first_row.get(6),"");
						Phone_final = ifnull(first_row.get(7),"");
						Grade_final = ifnull(first_row.get(8),"");
					}
				}
			}
		}
	}
}
country = "USA";
if(isNull(State_final))
{
	country = "";
}
update_map = Map();
update_map.put("NCES_School_ID",nces_id_final);
update_map.put("School_Website",Website_final);
update_map.put("Address",Address_final);
update_map.put("Address_2",Address2_final);
update_map.put("City",City_final);
update_map.put("State",State_final);
update_map.put("Zip",Zip_final);
update_map.put("School_Phone",Phone_final);
update_map.put("Grade_Level",Grade_final);
update_map.put("Country",country);
update_rec = zoho.crm.updateRecord("Schools",st_school_id,update_map);
info st_school_id + " Updated";
/***** === Diagnostics === *****/
diag = Map();
diag.put("school_id",st_school_id);
diag.put("selected_nces_school_id",nces_id_final);
diag.put("model_confidence",confidence);
used_fallback = false;
if(selected_nces == "" || selected_nces == null)
{
	used_fallback = true;
}
diag.put("used_fallback",used_fallback);
return diag.toString();
}