diff --git a/public/src/modules/api.js b/public/src/modules/api.js
index ca619ec16e..8c9485e557 100644
--- a/public/src/modules/api.js
+++ b/public/src/modules/api.js
@@ -37,123 +37,99 @@ function call(options, callback) {
 }
 
 async function xhr(options, cb) {
+	// Normalize body based on type
+	const { url } = options;
+	delete options.url;
+
+	if (options.data && !(options.data instanceof FormData)) {
+		options.data = JSON.stringify(options.data || {});
+		options.headers['content-type'] = 'application/json; charset=utf-8';
+	}
+
+	// Allow options to be modified by plugins, etc.
+	({ options } = await fireHook('filter:api.options', { options }));
+
 	/**
-	 * N.B. fetch api is only used when payload is a FormData object!
-	 *
-	 * This is because the passed-in options are different between fetch/jQuery .ajax()
-	 * If we updated the code to use only fetch, it would be a breaking change.
+	 * Note: pre-v4 backwards compatibility
 	 *
-	 * Prior to v3.3 there was no support for sending in FormData, so the addition of fetch
-	 * handling is not breaking.
+	 * This module now passes in "data" to xhr().
+	 * This is because the "filter:api.options" hook (and plugins using it) expect "data".
+	 * fetch() expects body, so we rename it here.
 	 *
-	 * Break this for v4 by making everything use fetch api.
+	 * In v4, replace all instances of "data" with "body" and record as breaking change.
 	 */
-
-	// Adjust options based on payload type
-	if (options.payload instanceof FormData) {
-		const url = options.url;
-		options.body = options.payload;
-		delete options.payload;
-		delete options.url;
-
-		// Allow options to be modified by plugins, etc.
-		({ options } = await fireHook('filter:api.fetchOptions', { options }));
-
-		await fetch(url, {
-			...options,
-		}).then(async (res) => {
-			const payload = await res.json();
-			if (Math.floor(res.status / 100) === 2) {
-				cb(null, (
-					payload &&
-					payload.hasOwnProperty('status') &&
-					payload.hasOwnProperty('response') ? payload.response : (payload || {})
-				));
-			} else {
-				cb(new Error(payload.status.message));
-			}
-		}).catch(cb);
-	} else {
-		options.data = JSON.stringify(options.payload || {});
-		options.contentType = 'application/json; charset=utf-8';
-		delete options.payload;
-
-		// Allow options to be modified by plugins, etc.
-		({ options } = await fireHook('filter:api.options', { options }));
-
-		$.ajax(options)
-			.done((res) => {
-				cb(null, (
-					res &&
-					res.hasOwnProperty('status') &&
-					res.hasOwnProperty('response') ? res.response : (res || {})
-				));
-			})
-			.fail((ev) => {
-				let errMessage;
-				if (ev.responseJSON) {
-					errMessage = ev.responseJSON.status && ev.responseJSON.status.message ?
-						ev.responseJSON.status.message :
-						ev.responseJSON.error;
-				}
-
-				cb(new Error(errMessage || ev.statusText));
-			});
+	if (options.data) {
+		options.body = options.data;
+		delete options.data;
 	}
+
+	await fetch(url, {
+		...options,
+	}).then(async (res) => {
+		const response = await res.json();
+		if (Math.floor(res.status / 100) === 2) {
+			cb(null, (
+				response &&
+					response.hasOwnProperty('status') &&
+					response.hasOwnProperty('response') ? response.response : (response || {})
+			));
+		} else {
+			cb(new Error(response.status.message));
+		}
+	}).catch(cb);
 }
 
-export function get(route, payload, onSuccess) {
+export function get(route, data, onSuccess) {
 	return call({
-		url: route + (payload && Object.keys(payload).length ? ('?' + $.param(payload)) : ''),
+		url: route + (data && Object.keys(data).length ? ('?' + $.param(data)) : ''),
 	}, onSuccess);
 }
 
-export function head(route, payload, onSuccess) {
+export function head(route, data, onSuccess) {
 	return call({
-		url: route + (payload && Object.keys(payload).length ? ('?' + $.param(payload)) : ''),
+		url: route + (data && Object.keys(data).length ? ('?' + $.param(data)) : ''),
 		method: 'head',
 	}, onSuccess);
 }
 
-export function post(route, payload, onSuccess) {
+export function post(route, data, onSuccess) {
 	return call({
 		url: route,
 		method: 'post',
-		payload,
+		data,
 		headers: {
 			'x-csrf-token': config.csrf_token,
 		},
 	}, onSuccess);
 }
 
-export function patch(route, payload, onSuccess) {
+export function patch(route, data, onSuccess) {
 	return call({
 		url: route,
 		method: 'patch',
-		payload,
+		data,
 		headers: {
 			'x-csrf-token': config.csrf_token,
 		},
 	}, onSuccess);
 }
 
-export function put(route, payload, onSuccess) {
+export function put(route, data, onSuccess) {
 	return call({
 		url: route,
 		method: 'put',
-		payload,
+		data,
 		headers: {
 			'x-csrf-token': config.csrf_token,
 		},
 	}, onSuccess);
 }
 
-export function del(route, payload, onSuccess) {
+export function del(route, data, onSuccess) {
 	return call({
 		url: route,
 		method: 'delete',
-		data: JSON.stringify(payload),
-		contentType: 'application/json; charset=utf-8',
+		data,
 		headers: {
 			'x-csrf-token': config.csrf_token,
 		},