SheetLink Forms beta

CORS for form endpoints: what actually needs it

Half of CORS advice is for problems you do not have. Here is which cross-origin form submissions need it, which skip it, and which fail for a different reason entirely.

First: the case where CORS does not apply at all

A plain HTML form posting cross-origin is not subject to CORS. When the browser submits

<form action="https://sheetlinkforms.com/f/slf_yourtoken" method="POST">
  <input name="email" type="email" required>
  <input name="_slhp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">
  <button type="submit">Send</button>
</form>

it performs a navigation, not a script-initiated request. CORS is a mechanism that governs what JavaScript may do across origins; navigations predate it and are exempt. This is why a static page on any domain can point its action at sheetlinkforms.com with zero configuration, and why forms worked cross-domain for decades before fetch existed. The 303 redirect to the thank-you page is part of the same navigation. If your form is this kind of form, you can stop reading - though the rest explains the errors you will meet the day you switch to AJAX.

Simple requests: fetch without a preflight

Script-initiated requests split into two classes. A simple request is one the browser sends immediately, no preliminary round trip: the method is GET, HEAD, or POST, there are no custom headers, and the Content-Type is one of exactly three values - application/x-www-form-urlencoded, multipart/form-data, or text/plain.

This means the following AJAX submission goes straight out, one request, no preflight:

await fetch("https://sheetlinkforms.com/f/slf_yourtoken", {
  method: "POST",
  body: new URLSearchParams({ email: "ada@example.com", _slhp: "" })
});

URLSearchParams as a body sets Content-Type: application/x-www-form-urlencoded, which is on the safelist. The browser attaches an Origin header and sends the POST. One subtlety worth being precise about: even if the server sent no CORS headers back, the request would still have reached the server - CORS at this stage only controls whether your script may read the response. The endpoint does send the right headers, so you can read the JSON reply - but the distinction matters when you debug.

Preflighted requests: what application/json costs

Content-Type: application/json is not on the safelist, so this version behaves differently:

await fetch("https://sheetlinkforms.com/f/slf_yourtoken", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "ada@example.com" })
});

Before the POST, the browser sends an OPTIONS request - the preflight - asking the server, via Access-Control-Request-Method and Access-Control-Request-Headers, whether a cross-origin POST with a JSON content type is welcome. Only if the server answers with matching Access-Control-Allow-* headers does the real POST follow. Fail the preflight and the POST is never sent at all - the console shows a CORS error and the server never saw your data.

The cost is one extra round trip and one more thing that can fail. The SheetLink endpoint answers preflights correctly, so JSON submissions work fine - but if you do not specifically want JSON semantics, posting urlencoded is one request instead of two.

The origin allowlist: a 403 is not a CORS error

CORS is enforced by the browser, for the visitor's protection. It does nothing against a script, a curl command, or a spam bot, none of which are obliged to respect it. That is why SheetLink Forms also offers a per-form origin allowlist, enforced on the server: when configured, posts whose origin is not on the list are refused with a 403, no matter what sent them.

The two mechanisms fail differently, and telling them apart saves debugging time:

  1. A CORS failure happens in the browser - the console prints a CORS message, and for preflighted requests the POST never leaves. The server may be perfectly willing.
  2. An allowlist failure happens on the server - the request arrives and is answered with 403. The browser did nothing wrong; the form's settings excluded that origin.

The classic allowlist trip-up is a staging or preview domain that is not listed. If production works and staging gets 403s, add the staging origin in the form's settings - details in the docs.

What sl.js does about all of this

If you would rather not hold the taxonomy in your head, the embed handles it:

<script src="https://sheetlinkforms.com/sl.js" data-token="slf_yourtoken"></script>

sl.js binds forms marked data-sheetlink and forms whose action points at /f/, and submits them over AJAX in a shape the endpoint accepts without drama, with inline success text on completion. It also injects the honeypot, adds the timing signal used in spam screening, and carries utm_* parameters and ad click IDs across pages - the parts of a form pipeline CORS never touches. The full picture is on how it works.

A short debugging order

When a cross-origin submission fails, check in this order:

  1. Read the console message. A CORS message names the missing header or the failed preflight; an HTTP status in the network tab is not a CORS problem.
  2. Look for the OPTIONS request in the network tab. If it is there and failed, your content type or a custom header triggered a preflight - consider posting urlencoded instead.
  3. A 403 means the origin allowlist (or a failed Turnstile challenge), not CORS. Fix the form's origin settings, not your headers.
  4. Reproduce with curl. curl ignores CORS entirely, so if curl succeeds and the browser fails, the issue is in the browser-side class of problems; if curl also fails, read the status code.

Try a live endpoint on the demo, or join the free beta from the waitlist.

FAQ

Do I need to configure CORS to use SheetLink Forms?

No. Plain HTML posts are navigations and bypass CORS entirely, and for AJAX submissions the endpoint already answers preflights and sets the response headers browsers require. The only origin-related setting you control is the optional per-form allowlist, which is a server-side gate, not CORS.

Why does my JSON fetch send an OPTIONS request first?

Because application/json is not one of the three preflight-exempt content types. The browser asks permission with an OPTIONS preflight before sending a cross-origin POST with that content type. The endpoint answers it correctly, but you can avoid the extra round trip by posting URLSearchParams (urlencoded) instead.

Why does curl work when the browser fails?

CORS is enforced by browsers on behalf of their users; curl and server-side code are not subject to it. When curl succeeds and the browser errors, the request itself is fine and the failure is in the browser-side rules - usually a failed preflight. When both fail, read the HTTP status instead.

Does the origin allowlist replace CORS?

They are complementary. CORS is browser-enforced and protects visitors; it cannot stop non-browser senders. The allowlist is server-enforced and refuses any post from an unlisted origin with a 403, whatever sent it. Use the allowlist when your form should only ever live on domains you control.

Can my script read the response after a cross-origin submit?

Yes. The endpoint returns {"ok":true,"id":"..."} to AJAX submissions and sends the CORS headers that permit your script to read it cross-origin. Note the asymmetry for simple requests: without those headers the POST would still arrive at the server - your script would just be blocked from reading the reply.

Related guides: AJAX form submission, from scratch and with sl.js · A contact form on a fully static site · Webhook form submissions, explained with working examples

Request an invite Developer docs