Fix · API endpoints answer without login and secrets ship in the bundle

Bots spamming my contact form and flooding my inbox

Your inbox fills with junk submissions or fake bookings, and the form on your live site has no visible way to stop them. The usual cause is that the check sits in the browser, or is missing, so a script can post straight to the endpoint.

Likely causes, most common first

Cause 1 · in 1 of 2 matching cases

A public booking link confirms every request automatically

If your scheduling tool confirms bookings the moment they arrive, a bot can fill the calendar and trigger notification emails. Cal.com is shown here; other tools have similar approval settings.

How to tell: Bookings from addresses you do not recognise show as confirmed, and their time slots are taken on your calendar.

Cause 2

The form endpoint accepts any request

The page may show a challenge widget, or no check at all, but the server-side handler does not verify anything before it sends the mail. A script can skip the page and post directly to the endpoint. The widget in the browser does not protect the form on its own.

How to tell: A curl request with no browser and no token returns a success status, and the message arrives in your inbox.

Cause 3

Nothing limits how often one client can submit

Even with a check in place, a client can send submissions in a tight loop if the handler counts nothing. Each request looks valid on its own, so the volume is what floods the inbox.

How to tell: Your logs show many submissions from the same address within a short window, with no 429 responses.

Check and fix it, step by step

  1. Post to the form endpoint without a browser

    Send a request with no challenge token and read the status code. A success status means the handler does not verify anything server-side, so the fix belongs in the handler.

    curl -si -d '[email protected]&message=probe' https://YOUR-SITE/api/contact | head -1

    Docs: developers.cloudflare.com →

  2. Verify the challenge token on the server before sending mail

    Add a challenge widget to the form, then have the handler send the token and your secret key to Siteverify. Send the email only if the result says success. The docs state that you must call Siteverify to complete the implementation.

    const token = body.get("cf-turnstile-response");
    const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: SECRET_KEY, response: token }),
    });
    const result = await res.json();
    if (!result.success) return new Response("Rejected", { status: 400 });

    Docs: developers.cloudflare.com →

  3. Handle expired and replayed tokens

    A token is valid for 300 seconds and can be validated once, so a visitor who leaves the form open or resubmits will be rejected with timeout-or-duplicate. Show a message asking them to complete the challenge again rather than treating it as a bot.

    Docs: developers.cloudflare.com →

  4. Rate limit the form endpoint

    Count submissions per client, typically by IP, and return 429 Too Many Requests once a client passes your threshold. Add a Retry-After header to tell the client how long to wait.

    return new Response("Too Many Requests", { status: 429, headers: { "Retry-After": "3600" } });

    Docs: developer.mozilla.org →

  5. Require approval for bookings on public links

    In Cal.com, open the link, go to Policies, then Privacy & security, toggle on Requires confirmation and save. Pending bookings send no confirmation email until you approve them, and they wait under the Unconfirmed tab.

    Docs: cal.com →

  6. Decide whether unconfirmed bookings should hold your calendar

    The setting Unconfirmed bookings still block calendar slots holds the time for a pending request. If bots are filling the calendar, turn it off so pending requests do not occupy slots. Then clear the existing backlog from the Unconfirmed tab.

    Docs: cal.com →

Quick check: curl -si -d '[email protected]&message=x' https://YOUR-SITE/api/contact | head -1 # 2xx with no token = open to bots

How often this shows up in our data

2 of the 937 verified cases from the last 12 months in our data match this symptom (0.2%). The most common cause was “A public booking link confirms every request automatically” (1 of 2); 1 didn't show which cause. How we collect and verify cases.

With Gemmein

On Gemmein, submissions are written to a collection governed by a plain-English rule for who may read and write it. Rate limiting and domain-locked keys are on by default, so a browser calling with your Live key from an unverified address is refused 403 origin_not_allowed, and a booking view lets you confirm or cancel each booking yourself.

Questions

Is a challenge widget on the page enough to stop form spam?

No. The Turnstile docs say the client-side widget alone does not protect your forms, and that your server must call the Siteverify API to complete the implementation.


Why are some real visitors rejected with timeout-or-duplicate?

A token is valid for 300 seconds and a replayed token is rejected. If someone leaves the form open or submits twice, ask them to complete the challenge again.


Which status code should a rate-limited form return?

429 Too Many Requests, which indicates the client has sent too many requests in a given amount of time. A Retry-After header may be included to say how long to wait.


Do pending bookings email the person who booked?

In Cal.com, a booking waiting for approval stays pending and no confirmation email is sent until you accept it. You find these under the Unconfirmed tab on the Bookings page.


Sources

Every cause and step above was checked against these pages on 3 Nov 2026.

The broader pattern

This is one symptom of a wider failure pattern: API endpoints answer without login and secrets ship in the bundle. The guide covers every cause we see for it, on any stack.

Get a heads-up when your stack breaks something

Leave your email and we'll let you know when something big changes for your stack. Unsubscribe any time by replying. Gemmein Limited. Research terms · Privacy

← All fixes