Skip to content
Antisy

Protect a PHP form from spam bots, without a puzzle

To protect a PHP form from bots, put a tag in the page, read the hidden antisy-token field when the form arrives, and send it with your secret to POST /v1/siteverify from your server: the response is allow or deny. A few lines of PHP and cURL, without a library.

As of .

What you need

  • A site declared in the Antisy dashboard (in French), with the host names of your pages.
  • The site key (32 characters): it is public and goes in the page; the verification secret (64 characters) stays on your server and is shown only once, when it is created.
  • Pages served over HTTPS.
  • PHP 8.0 or later with the cURL extension: the function of the documentation uses curl_init and the mixed type.

See: Documentation: keys and test environments

Is it available today?

Access is by waiting list. The API and the dashboard (in French) have been in service since 27 October 2026, hosted at Infomaniak in Geneva; no customer is in production yet.

The examples of this guide follow version 1 of the API, as the documentation describes it. To try them without protecting a real form, the documentation describes test keys; a test key never goes into production.

See: Documentation: test keys · The pilot · Join the waiting list

1. The page: a tag and a form

In the <head> of the page that carries the form, the tag of the script with your site key. The form is an ordinary HTML form sent by POST to your own site: you write no JavaScript. The example sends to /contact; adapt the address to your file, for example /contact.php.

page.html
<script src="https://api.antisy.com/v1/i/l.js"
        data-site="VOTRE_CLE_DE_SITE" defer></script>

<form method="post" action="/contact">
  <input name="name" autocomplete="name">
  <input name="email" type="email" autocomplete="email">
  <textarea name="message"></textarea>
  <button>Envoyer</button>
</form>

On submission, the script holds the form while it obtains a token, creates the hidden antisy-token field, fills it in and sends the form again (6 seconds at most). By default, the script covers the POST forms whose submission address is that of your site.

See: Documentation: which forms the script covers

2. The server: the PHP verification function

Save the function of the documentation in antisy.php, as it is. It reads the secret from the ANTISY_SECRET environment variable and returns allow or deny.

antisy.php
<?php
/**
 * Vérifie le jeton Antisy (mode simple). Rend le verdict : "allow" ou "deny".
 *
 * - jeton vide ou absent : envoyé tel quel, Antisy décide ; jeton non vide inutilisable : "deny", sans appel ;
 * - Antisy ne répond pas (réseau, délai, 5xx) : "allow" et on journalise (fail-open) ;
 * - 429 : ANTISY_ON_RATE_LIMIT ;
 * - tout autre statut que 200 (400, 401, 413, 415...) : erreur de VOTRE appel, journalisée,
 *   tentative non vérifiée : "deny". Jamais "allow".
 */
const ANTISY_ON_RATE_LIMIT = 'allow'; // 'deny' for a sensitive form: an attacker can cause a 429

function antisy_verdict(mixed $token): string
{
    if ($token === null) {
        $token = ''; // missing field: treated as empty
    }
    // Empty: sent as it is, Antisy decides (collection on: deny; off: the engine decides).
    // Not empty: 1 to 2,048 printable ASCII characters; /D: “$” does not tolerate a final line break.
    if ($token !== '' && (!is_string($token) || !preg_match('/^[\x20-\x7e]{1,2048}$/D', $token))) {
        return 'deny'; // a form array, a token too long or crafted: what Antisy would answer
    }

    $ch = curl_init('https://api.antisy.com/v1/siteverify');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 3, // seconds; beyond that, fail-open
        CURLOPT_FOLLOWLOCATION => false, // no redirect followed (the default, written so that no one turns it on): a 3xx is an error of YOUR call
        CURLOPT_POSTFIELDS => http_build_query([
            'secret' => getenv('ANTISY_SECRET'), // siteverify secret; never hard-coded nor on the client side
            'response' => $token,
        ]),
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    // The only case where the submission is let through: Antisy does not answer.
    if ($body === false || $status >= 500) {
        error_log("Antisy injoignable, envoi laissé passer (fail-open): {$error} (HTTP {$status})");
        return 'allow';
    }
    if ($status === 429) {
        error_log('Antisy 429 (voie saturée): ' . ANTISY_ON_RATE_LIMIT);
        return ANTISY_ON_RATE_LIMIT;
    }
    if ($status !== 200) {
        error_log("Antisy HTTP {$status} : erreur de l'appel, à corriger ; tentative non vérifiée");
        return 'deny';
    }
    $result = json_decode($body, true);
    if (is_array($result) && is_bool($result['success'] ?? null)) {
        if ($result['success']) {
            return 'allow'; // the only acceptance
        }
        return 'deny'; // success false: refusal (the verdict is “deny”)
    }
    error_log('Réponse Antisy illisible, tentative non vérifiée');
    return 'deny';
}

  • A token that is not empty must have the shape of a token (1 to 2,048 printable ASCII characters); otherwise the function returns deny without calling Antisy.
  • The call goes by POST to https://api.antisy.com/v1/siteverify, with secret and response in the body, never in the address, and a deadline of 3 seconds.
  • No redirect is followed: a 3xx response is an error of your call, never a pass.
  • success is true if and only if the verdict is allow: it is the only acceptance.

3. The form handler

A complete file, contact.php, that refuses the submission without saying why when the verdict is not allow:

contact.php
<?php
// contact.php: receives the form of the page.
require __DIR__ . '/antisy.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

if (antisy_verdict($_POST['antisy-token'] ?? '') !== 'allow') {
    http_response_code(403);
    exit('Envoi refusé.'); // generic refusal, without saying why
}

// … save or send the message

header('Location: /merci.html', true, 303);
exit;

The field is read with an empty default value (?? ''): if the script is blocked, the field is missing, and the function sends an empty token, which Antisy reads as a missing token.

Where does the secret go?

The function reads getenv('ANTISY_SECRET'). Set the variable in the environment of the web server or of PHP-FPM, for example in the pool:

Configuration of the PHP-FPM pool
# Environment variable of the web server (here, a PHP-FPM pool): the secret goes neither in the page nor in the repository.
env[ANTISY_SECRET] = VOTRE_SECRET_DE_VERIFICATION

A PHP-FPM pool clears the environment by default (clear_env): the env[...] directive passes the variable on to PHP. Write the secret neither in the page nor in a file of the repository, and never show it in a response or in a log.

What does the verification answer?

The response is a JSON object. success is true if and only if verdict is allow. There are only these two outcomes, and the response never gives the reason: the refusal you show the visitor is generic too.

A token is valid for 60 seconds and can be verified only once. A second call with the same token is refused.

Response of POST /v1/siteverify (HTTP 200)
{
  "success": true,
  "verdict": "allow",
  "flags": [],
  "error-codes": [],
  "challenge_ts": "2026-09-24T10:00:00Z",
  "hostname": "shop.example",
  "action": "contact",
  "event_id": "evt_01J7X4M2Q9K8ZP3R5T6V7W8Y9A"
}

See: Documentation: the request, the response and the error codes

What if Antisy does not answer?

The function lets the submission through in one case only: Antisy does not answer (network down, deadline of 3 seconds exceeded, 5xx error). An Antisy outage must not close your form.

Any other HTTP error (400, 401, 413, 415) comes from your call: the function refuses the submission and logs it, never a pass. A 429 is a decision that is yours: the constant ANTISY_ON_RATE_LIMIT is allow by default; set deny for a sensitive form.

See: Documentation: the request, the response and the error codes

What happens if the script is blocked?

A CSP policy that refuses the script, an ad blocker, a page served over HTTP or a visitor without JavaScript stop the script from running. The form is then sent anyway, without the antisy-token field: read it with an empty default value.

What follows depends on the site’s “Collecte du navigateur” (browser collection) setting, in the dashboard, which is in French. Collection on, a submission without a token does not get through; collection off, a missing token is not held against it. A new site starts with collection off.

See: Documentation: if the script does not run

What a bot check does not replace

No bot check is perfect, and Antisy announces no detection rate. It adds to the other defences of a form, such as limiting the number of submissions; it does not replace them.

Frequently asked questions

Where should the Antisy secret go in a PHP application?

In an environment variable of the server, named ANTISY_SECRET: the function of the documentation reads it with getenv. The secret never goes in the page or in the repository; the dashboard shows it only once, when it is created.

What to do with a PHP form received without a token?

Read the field with an empty default value ($_POST['antisy-token'] ?? '') and call the function: Antisy decides. Browser collection on, a submission without a token does not get through; off, the engine decides on the rest of the assessment.

Is a PHP library needed to call Antisy?

No. The verification is a single HTTP call by POST, which the function of the documentation makes with cURL. There is no Antisy PHP library: the Node server kit serves that language only.

See also: Protect a Next.js form from bots, without a puzzle · Protect a WordPress form from spam bots, without a plugin · All guides · Documentation: step-by-step guide