Cloudflare Turnstile not rendering consistently, or adding a `cf-turnstile-response` input

Following the CloudFlare docs I’ve placed their script tag in the head of my html:

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

and rendered the widget on the client side, in this case implicitly and within the form element – which from the looks of their demo doesn’t require a callback:

// My implementation

  <div class="form-item--default relative">
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="4" class="..."></textarea>
  </div>
  <div class="">
    <div class="cf-turnstile" data-sitekey="3x00000000000000000000FF"></div> // <------ here
    <button type="submit" class="...">Submit</button>
  </div>
</form>

The site key 3x00000000000000000000FF is a test key that should

force an interactive challenge

Except it regularly doesn’t seem to. I’d say it’s rendering 1 in 3 page reloads, and frequently not rendering after a reload where I’d previously not bothered with the challenge (which suggests to me that it’s not simply validating me silently).

I’m not using the method or action attributes, as their example does: <form method="POST" action="/handler">, rather I’m getting the form data like this:

  e.preventDefault();
            
  const formData = new FormData(e.target);
  const formObject = Object.fromEntries(formData);

  // validate formObject

  // make call to backend
});

It also doesn’t seem to be adding a hidden input, cf-turnstile-response, to my form data (although it’s doing something because my validation function complains of a null input when the widget does challenge me).

Even when I remove the validation and check for the token server-side const token = req.body["cf-turnstile-response"];, it comes out null

I’m just currently completely stuck!