Skip to content
Antisy
Documentation · Server-side verification

Verify a token server side: POST /v1/siteverify

Your server receives the form with the antisy-token field, which the script filled in, and has it verified server side with a single call to siteverify. The response says whether the visitor is accepted.

The siteverify request

POST https://api.antisy.com/v1/siteverify, with a application/x-www-form-urlencoded or application/json body.

secret
Required. Your verification secret (64 alphanumeric characters), in the body and nowhere else.
response

The token, as it arrived in the antisy-token field (filled in by the script) or the x-antisy-token header (set by your page). A string of 1 to 2048 printable ASCII characters. Empty or missing, it is read as a missing token.

Check it on your side before the call. Outside that shape, the API treats it as a forged token: 200, success at false, verdict at deny, invalid-input-response.

idempotency_key
Optional. A UUID; any other value is refused. See below.
remoteip
Do not send it. It is ignored, whatever its value, and never refused: remove it.
  • Do not send an Authorization header. Its presence switches the request to another mode, the SDK’s, where the secret is not a valid credential: a 401 response, in a body of another shape. A secret sent as Authorization: Bearer is refused in the same way.
  • Call the route from your server, never from a browser: it sends no CORS header, a browser cannot read the response.

The response

Response, status 200
{
  "success": true,
  "verdict": "allow",
  "flags": [],
  "error-codes": [],
  "challenge_ts": "2026-09-24T10:00:00Z",
  "hostname": "shop.example",
  "action": "contact",
  "event_id": "evt_01J7X4M2Q9K8ZP3R5T6V7W8Y9A"
}

success
true if and only if the visitor is accepted. It is the field to read if you want only a boolean.
verdict
allow or deny, and nothing else. success is true if and only if verdict is allow: the two say the same thing.
flags
The list of states that are not errors. It is open: ignore the ones you do not know (see below).
error-codes
The list of error codes, empty when nothing is wrong (see below).
challenge_ts
The time the token was deposited, in ISO 8601 format in UTC, on an allow that has a deposit behind it. An allow without a deposit carries it at null, for example: collection off and token missing, site or organisation suspended, test token, a deposit our database could not write (with internal-error). So do not make it a requirement for accepting a visitor. null too on any refusal, whatever its cause.
hostname
On an allow, the host name of the page that asked for the token, when its origin is attested. null otherwise, and on any refusal.
action
On an allow, the label passed to token() or set by data-antisy-action on the form, or null if there was none. null too on any refusal.
event_id
The identifier of this verification, of the form evt_ followed by 26 characters. Quote it if you write to support (support@antisy.com) about a case.

The response contains no score, no threshold and no reason, in any mode: a verdict, never the detail that produced it. Each reason returned would teach whoever gets around the check what stopped them, and they stay with Antisy. The error codes say what your integration must fix, not what the detection saw. A decoy and a submission refused for what its making proves receive the same response as any refusal. It carries Cache-Control: no-store and the X-Antisy-Api-Version: 1 header.

The flags

test_key
The verification was made with a test secret.
over_quota
The monthly quota of the Free tier is exceeded: the score no longer decides the response, but the gate stays (a missing token while the collection of the site is on is refused, as is a token that is invalid, forged, replayed, expired, from another site or whose computation did not finish in time, and a visitor on your block list too). A state of your account, to be logged.
degraded
A source of Antisy was not available for this verification: the response is given without it. Log it (see internal-error).
idempotent_replay
A replayed response: the same idempotency_key within 60 seconds.

Fields, flags and codes can be added without a change of version. A break in compatibility would create a /v2/ route: read what you know and ignore the rest.

What to do with each verdict

allow

success at true. Publish or save the message.

deny

success at false. The visitor does not get through: a generic refusal, without revealing the reason. The visitor learns nothing of what happened; only your server knows.

There are only these two outcomes. If you want moderation or a confirmation by e-mail, you decide it, on your own signals. How strictly Antisy refuses is a setting of your site, in the dashboard (in French): Strict, Balanced (the default) or Permissive, described in the quick start.

This call suits low-stakes forms. It does not tie the token to an account: do not use it for a login or a sign-up. The Node SDK (Express, Fastify, Next.js) will be published on npm with its documentation; it is not published yet.

What never gets through

Whatever the verification mode of your site, these cases are refused: success at false, verdict at deny.

  • A missing token, on a site whose browser collection is on (missing-input-response). On a site whose collection is off, the absence is not held against it: a new site starts with collection off.
  • A token that is invented, unreadable, of a shape outside the limits, or issued for another of your sites (invalid-input-response).
  • A token already verified, or expired, even by a few seconds (timeout-or-duplicate).
  • A submission whose making is proven: a program made it rather than the browser. The response carries no error code and does not say what gave it away.
  • A token whose background computation did not finish in time (a very slow device, a hidden tab, a totalTimeoutMs deadline too short): it leaves without its solution. It is not a proof of making, but it is refused all the same, without an error code; a new attempt does the computation again with a fresh token (the deadline of the computation).
  • A missing or invalid token is not rescued by an allow list, when Antisy has set one for your site, at your written request to support: it exempts from the assessment, not from the token.

This rule also holds beyond the monthly quota of the Free tier. The score then no longer decides the response: a submission that is none of these gets through, with the over_quota flag. But the gate stays closed: a token that is missing (collection on), invalid, forged, replayed, expired or from another site, or whose computation did not finish in time, receives success at false as under the quota, with the same error codes and over_quota in flags. So the flag changes nothing in your code: it is a state of your account, to be logged. Your submissions without a token or with a fake token count, as before, towards the quota. A block list set by Antisy for your site, at your written request to support (the dashboard does not manage these lists), holds too: a blocked visitor receives success at false beyond the quota, with or without a token; an allow list, for its part, does not exempt from the token.

Validity and single use

  • A token is valid for 60 seconds after it is deposited. Past that delay, the verification returns success at false and timeout-or-duplicate, whatever the rest.
  • It can be verified only once. A second call with the same token returns timeout-or-duplicate.

Replaying a call: idempotency_key

If your call may have succeeded but you did not read the response (a cut, a timeout), sending it again without care would give timeout-or-duplicate. Send an idempotency_key (a UUID): a new call with the same key within 60 seconds replays the same verdict and the same event_id, with the idempotent_replay flag, without verifying the token again. The error codes of the first response are not repeated: error-codes is empty, and flags carries only idempotent_replay, plus over_quota if the first response carried it. This replay assumes that the first response was kept: that is never the case with a test secret, nor when the first call failed before keeping it. The token is then assessed again without being counted again: a new event_id, without idempotent_replay, and a verdict that may differ from the first.

Your server generates the key: a fresh UUID per submission, reused only to retry that call. Never read it from a field of the form, a header or the visitor’s request: a key the visitor chooses would let them replay the response of a submission already accepted, and get through again with the same token for 60 seconds.

curl, with an idempotency key
# One key per token: created once, then reused as it is on each new attempt.
KEY=$(uuidgen)
for tentative in 1 2 3; do
  printf 'secret=%s' "$ANTISY_SECRET" | curl -sS --max-time 3 https://api.antisy.com/v1/siteverify \
    -d @- \
    --data-urlencode response="$ANTISY_TOKEN" \
    -d idempotency_key="$KEY" && break
  sleep 1
done

Error codes

The first five codes come with a 200 response that carries a verdict. So a missing or refused secret is not an HTTP error: it is a refusal (deny), so that the route cannot be used to test secrets and a configuration error shows from the first attempt, without leaving your site open. The last three are error statuses, in a body without a verdict:

Body of an error (429)
{ "success": false, "error-codes": ["rate-limited"] }

missing-input-secretStatus 200

The secret field is missing from the body of the request. It is not an HTTP error: the response is a refusal (success at false, verdict deny), so that a configuration error shows from the first attempt.

Fix the call. As long as it is wrong, every visitor is refused: log it as an error.

invalid-input-secretStatus 200

The secret is unknown, revoked, malformed or of another type (an evaluation key is not a verification secret), or the site is closed. The same response as for a missing secret: the route never says more.

Check the key in the “Clés” (Keys) tab of the site. The same effect as the previous one: every visitor is refused.

missing-input-responseStatus 200

The response field is empty, on a site whose browser collection is on. The visitor is refused, whatever the verification mode of the site.

The script did not run, or the form is not covered by the script and the page did not call token(). See the page on the browser script.

invalid-input-responseStatus 200

The token is not valid for this site: invented, altered, issued for another of your sites, or of a shape outside the limits (more than 2,048 characters, non-printable characters). The visitor is refused.

Check that you pass the token on as it arrived, without truncating it, and that it does come from the page of this site.

timeout-or-duplicateStatus 200

The token has already been verified, or it is more than 60 seconds old. The visitor is refused.

Verify a token only once. To replay a call after a cut, send idempotency_key. Otherwise, have the form sent again: the browser will produce a new token.

bad-requestStatus 400, 405, 415

A request malformed by your call: unreadable body, content type or character set not accepted, compressed body, unknown field, idempotency_key that is not a UUID, a method other than POST. The body carries no verdict.

The fault is in your call. Log it as a bug. The submission has not been verified: do not let it through, refuse it.

rate-limitedStatus 429

Too many calls on this lane. The Retry-After header gives the delay, in seconds. An attacker can cause this status by volume.

By default, let it through and log it. For a sensitive form, refuse: see “When the API does not answer”.

internal-errorStatus 500, 503

An outage at Antisy, a database unavailable for example. The code can also come with an allow: at Antisy, this token could not be recorded.

Let the submission through and log it.

A method other than POST receives 405, in the error body shown above, with bad-request.

A suspended site or organisation: your secret then answers success at true and verdict at allow, with no flag and no error code, apart from test_key for a test secret. A suspension of the account does not break your forms, but nothing is verified in the meantime. A closed site, for its part, is refused like an unknown secret (invalid-input-secret).

When the API does not answer

Rule: let through only when Antisy does not answer

If you write “if success is false, refuse”, you will block all your forms during an Antisy outage. An outage on our side must not cost you your visitors. But “let through” holds only for a real absence of response: a robot that causes an error must not get a pass out of it.

  1. Check the token before the call. A token that is there is a string of 1 to 2048 printable ASCII characters. Otherwise, do not call Antisy: the submission is not verified, refuse it. An empty token, for its part, is sent as it is: Antisy knows whether the site expects a token.
  2. Let through, and log, only on a network error, a DNS failure, a timeout (3 seconds is the timeout we recommend) or a 5xx response.
  3. Never let through on 400, 401, 413, 415 or any other 4xx status apart from the 429. It is a fault of your call: log it as a bug to fix, and treat the submission as not verified: refuse it.
  4. The 429 is a choice. Retry-After gives the delay in seconds. With a test secret, a 429 can also mean that your test secrets have used up their 1,000 calls of the day, or those of the month (limits): it then lasts until the next midnight UTC, or until the first of the next UTC month. Our examples let through by default, as for an outage. But an attacker can cause a 429 by volume: the lane is limited per key, and a flood of submissions on your form reaches that limit, for all your visitors, as long as it lasts; its submissions get through too. For a sensitive form, refuse on a 429 (deny): it is the protective choice, at the price of refusing legitimate visitors during a saturation, who will try again after Retry-After. The default behaviour is set by a constant in the Node example. A 503 that carries Retry-After is load shedding: the instance has reached its capacity, and a third party can cause it by volume, like the 429. The examples treat it as an outage; for a sensitive form, refuse on this 503 too, with the same value as for the 429.
  5. A missing or refused secret does not come under this rule: the response is a refusal with a 200 (deny). Log missing-input-secret and invalid-input-secret as configuration errors: all your visitors are refused as long as they last, and nothing else breaks.

Letting through during an outage is a choice: robots can get through with it. It is the price of never blocking a visitor because of Antisy.

Server-side verification examples: PHP, Python, Go

The verification is an HTTP call: no library to install. Here is the same example in PHP, in Python and in Go, with the same rules as the curl and Node examples of the quick start.

What each example does

  1. It checks the token before the call. A token that is there is a string of 1 to 2048 printable ASCII characters; otherwise the example does not contact Antisy and refuses, as Antisy would. An empty token is sent as it is: Antisy knows whether the site expects a token.
  2. It calls POST /v1/siteverify, with a form body that carries secret and response, and a timeout of 3 seconds. The secret comes from the ANTISY_SECRET environment variable: it is never hard-coded, never a command-line argument, never in a header.
  3. It reads only success: true is the only acceptance. An unreadable response is a submission not verified, so refused.
  4. It lets through only when Antisy does not answer: network error, timeout, 5xx response. It logs each time.
  5. It never lets through on an error of your call: 400, 401, 413, 415 and any other 4xx, nor on a 3xx redirect, which it does not follow: a redirect would replay the body, secret included, to another address.

The 429 is a choice that is yours: a constant at the top of each example sets it, allow by default. For a sensitive form, set deny; the section when the API does not answer says why. The examples treat any 5xx as an outage; to refuse on a 503 that carries Retry-After too, handle it before the case of the 5xx, with the same value.

The examples are those of the API’s guide. Antisy’s tests run them against a fake local server, on the same cases: response accepted, refused, outage, 429, error of your call, unreadable response, timeout and redirects.

PHP

With PHP’s curl extension (curl_init) and PHP 8 or later: the signature of the function uses the mixed type.

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';
}

In your form handler
// In the handler of your contact form:
$verdict = antisy_verdict($_POST['antisy-token'] ?? '');
if ($verdict === 'allow') {
    // publish / save the message
} else {
    // deny: generic refusal, without saying why
}

Python

Standard library only. urllib follows a 301, 302 or 303 redirect by default: the example installs an opener that follows none.

antisy.py
import http.client
import json
import logging
import os
import re
import urllib.error
import urllib.parse
import urllib.request

API = "https://api.antisy.com/v1/siteverify"
# 429: "allow" (the default, as in the Node kit); "deny" for a sensitive form, because an
# attacker can cause a 429 by volume.
ON_RATE_LIMIT = "allow"
# 1 to 2,048 printable ASCII characters; `fullmatch` (and not `$`, which tolerates a final line break).
USABLE_TOKEN = re.compile(r"[\x20-\x7e]{1,2048}")


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """Aucun suivi de redirection : urllib suit un 301, 302 ou 303 par défaut ; la réponse 3xx est alors une erreur de VOTRE appel."""

    def redirect_request(self, *args, **kwargs):
        return None


_OPENER = urllib.request.build_opener(_NoRedirect)


def antisy_verdict(token) -> str:
    """Rend le verdict Antisy : "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 : 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".
    """
    if token is None:
        token = ""  # missing field: treated as empty
    # Empty: sent as it is, Antisy decides (collection on: deny; off: the engine decides).
    if token != "" and (not isinstance(token, str) or USABLE_TOKEN.fullmatch(token) is None):
        return "deny"  # too long, crafted, or not a string: what Antisy would answer

    data = urllib.parse.urlencode(
        {
            "secret": os.environ["ANTISY_SECRET"],  # siteverify secret; never on the client side
            "response": token,
        }
    ).encode()
    try:
        with _OPENER.open(urllib.request.Request(API, data=data), timeout=3) as response:
            body = response.read()
    except urllib.error.HTTPError as error:
        if error.code >= 500:
            logging.warning("Antisy HTTP %s, envoi laissé passer (fail-open)", error.code)
            return "allow"
        if error.code == 429:
            logging.warning("Antisy 429 (voie saturée) : %s", ON_RATE_LIMIT)
            return ON_RATE_LIMIT
        logging.error("Antisy HTTP %s : erreur de l'appel, à corriger ; tentative non vérifiée", error.code)
        return "deny"
    except (urllib.error.URLError, http.client.HTTPException, OSError) as error:
        # Network, deadline, DNS, RemoteDisconnected, ConnectionResetError, IncompleteRead: Antisy did
        # not answer (URLError, TimeoutError and ConnectionError are OSErrors; they are named for
        # the reader). It is the only case, with the 5xx, where the submission is let through.
        logging.warning("Antisy injoignable (%s), envoi laissé passer (fail-open)", error)
        return "allow"
    try:
        result = json.loads(body)
        success = result.get("success")
    except (ValueError, AttributeError):
        success = None
    if success is True:
        return "allow"  # the only acceptance
    if success is False:
        return "deny"
    logging.error("Réponse Antisy illisible, tentative non vérifiée")
    return "deny"

In your form handler
# In the handler of your contact form:
verdict = antisy_verdict(form.get("antisy-token", ""))
if verdict == "allow":
    pass  # publish / save the message
else:
    pass  # deny: generic refusal, without saying why

Go

Standard library only. The HTTP client is your own, with a timeout of 3 seconds: that of http.DefaultClient is infinite. The check of the token is a loop, because Go’s regular expressions refuse a repetition count above 1,000.

antisy/verify.go
// Package antisy verifies an Antisy token in simple mode: Verdict returns "allow" or "deny". The same rules as the examples above.
package antisy

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

const apiURL = "https://api.antisy.com/v1/siteverify"

// 429: "allow" (the default, as in the Node kit); "deny" for a sensitive form, because an
// attacker can cause a 429 by volume.
const onRateLimit = "allow"

// 1 to 2,048 printable ASCII characters (0x20 to 0x7E). A loop and not a regular expression: those of Go refuse a
// repetition count above 1,000 (`{1,2048}` does not compile), and a final line break does not get through either.
func usableToken(token string) bool {
	if len(token) < 1 || len(token) > 2048 {
		return false
	}
	for i := 0; i < len(token); i++ {
		if token[i] < 0x20 || token[i] > 0x7e {
			return false
		}
	}
	return true
}

// A client of its own, with a deadline of 3 s (that of http.DefaultClient is infinite); beyond it, fail-open.
// No redirect followed: a 307 would replay the body, secret included, to another address.
var client = &http.Client{
	Timeout:       3 * time.Second,
	CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}

// Verdict returns the Antisy verdict: "allow" or "deny".
//
//   - empty or missing token: sent as it is, Antisy decides; unusable non-empty token: "deny", without a call;
//   - Antisy does not answer (network, deadline, response cut off, 5xx): "allow", and it is logged (fail-open);
//   - 429: onRateLimit;
//   - any status other than 200 (400, 401, 413, 415...): an error of YOUR call, logged,
//     attempt not verified: "deny". Never "allow".
func Verdict(token string) string {
	// Empty: sent as it is, Antisy decides (collection on: deny; off: the engine decides).
	if token != "" && !usableToken(token) {
		return "deny" // too long or crafted: what Antisy would answer
	}

	response, err := client.PostForm(apiURL, url.Values{
		"secret":   {os.Getenv("ANTISY_SECRET")}, // siteverify secret; never hard-coded nor on the client side
		"response": {token},
	})
	if err != nil {
		// Network, DNS, deadline exceeded: Antisy did not answer. The only case, with the 5xx, where the submission is let through.
		// err may quote the URL of the call, never the body: the secret is not in the log.
		log.Printf("antisy injoignable, envoi laissé passer (fail-open): %v", err)
		return "allow"
	}
	defer response.Body.Close()
	body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))

	switch {
	case response.StatusCode >= 500:
		log.Printf("antisy HTTP %d, envoi laissé passer (fail-open)", response.StatusCode)
		return "allow"
	case response.StatusCode == http.StatusTooManyRequests:
		log.Printf("antisy 429 (voie saturée) : %s", onRateLimit)
		return onRateLimit
	case response.StatusCode != http.StatusOK:
		log.Printf("antisy HTTP %d : erreur de l'appel, à corriger ; tentative non vérifiée", response.StatusCode)
		return "deny" // 400, 401, 413, 415... never “allow”
	}
	if readErr != nil {
		log.Printf("antisy : réponse coupée (%v), envoi laissé passer (fail-open)", readErr)
		return "allow" // Antisy did not finish answering: it is an outage, not a refusal
	}
	var result struct {
		Success *bool `json:"success"`
	}
	if json.Unmarshal(body, &result) != nil || result.Success == nil {
		log.Printf("antisy : réponse illisible, tentative non vérifiée")
		return "deny"
	}
	if *result.Success {
		return "allow" // the only acceptance
	}
	return "deny"
}

In your form handler
// In the handler of your contact form (net/http):
func contact(w http.ResponseWriter, r *http.Request) {
	// PostFormValue returns "" when the field is missing: treated as empty.
	if antisy.Verdict(r.PostFormValue("antisy-token")) != "allow" {
		http.Error(w, "refusé", http.StatusForbidden) // deny: generic refusal, without saying why
		return
	}
	// publish / save the message
}

Another language

Copy what the examples do, not their syntax.

  • The body is a form (application/x-www-form-urlencoded) that carries secret and response, or a JSON object with the same fields.
  • No Authorization header: its presence switches the request to another mode (the request).
  • No redirect followed, and a timeout of 3 seconds.
  • The same cases, in the same order: the token checked, the call, the outage that lets through, the error of your call that refuses, success that decides.

Login and sign-up

These examples suit low-stakes forms. They do not tie the token to an account: do not use them for a login or a sign-up. The Node SDK is not published yet; the step-by-step guide says what this documentation covers.

Reporting the real outcome of an attempt (optional)

Antisy measures its rules against what really happened: a sign-up whose address is confirmed, a fraud you have established. Without the Node kit, you say so with the same secret as for the verification, in one call. It is measurement, never a decision: the signal produces no verdict, changes nothing live, and the response carries none. Not sending it costs your protection nothing.

POST https://api.antisy.com/v1/feedback, without an Authorization header (its presence switches to the SDK’s mode, signed and encrypted), with a application/x-www-form-urlencoded or application/json body. Three fields, and nothing else: one field more is a 400 (bad-request).

secret
The same verification secret as for /v1/siteverify, in the body and nowhere else.
event_id
The event_id that siteverify returned for this verification (evt_ followed by 26 characters). Keep it with the object it describes, the row of the sign-up or of the message: it is the only way to report later.
outcome
success, failure, account_confirmed_fraud or false_positive. The first three describe the attempt; false_positive says that Antisy’s verdict seemed wrong to you, and is counted apart. Send only what you know: an account whose address is confirmed, not an account merely created.
curl
# ANTISY_SECRET:   your verification secret, in the environment, never hard-coded.
# ANTISY_EVENT_ID: the event_id that siteverify returned for this verification.
# To run after answering your visitor. No -L: no redirect followed.
printf 'secret=%s' "$ANTISY_SECRET" | curl -sS --max-time 3 -w '\n%{http_code}\n' \
  https://api.antisy.com/v1/feedback \
  -d @- \
  --data-urlencode event_id="$ANTISY_EVENT_ID" \
  --data-urlencode outcome=success

Response
{"success":true,"error-codes":[]}
200

The body of the response always has two keys: success and error-codes. Here success only says “report taken”, not a verdict; there is never a verdict, a score, a reason or an event_id. The statuses are real statuses, unlike siteverify: a 4xx is never to be retried, a 429 or a 5xx is, later, once or twice. No decision depends on the response: log it.

200
error-codes empty: taken, or already taken identically (the same outcome for the same event: nothing is rewritten), or set aside without recording anything (test secret, site or organisation suspended).
401
missing-input-secret or invalid-input-secret: secret missing; unknown, revoked, of a closed site, or an evaluation key (the same response for all). Fix the configuration.
400 missing-input-event
event_id missing: fix the call.
400 invalid-input-event
event_id malformed, or an event that this secret cannot report (non-existent, from another site, born of another lane, or without a record). A single refusal for all these cases. Do not retry.
400 missing-input-outcome
outcome missing: fix the call.
400 invalid-input-outcome
outcome not in the list: fix the call.
409 outcome-conflict
Another outcome has already been reported for this event. Do not retry.
410 event-expired
The event is more than 30 days old. Do not retry.
422 daily-limit-reached
Daily cap of this mode reached. Do not retry; resume tomorrow (UTC day) if the outcome matters to you.
400, 405, 413, 415
bad-request: unknown field (including a note or a date), a body that is unreadable or of more than 16 KiB, a method or content type not read.
429, 5xx
rate-limited with Retry-After, or internal-error (503 with Retry-After in case of load shedding). Try again later.

The limits

  • The event must be born of a siteverify verification in simple mode of this site, and be less than 30 days old. A single outcome per event.
  • Some siteverify responses carry an event_id that designates no recorded event: secret refused, site suspended, test token, a body of more than 16 KiB, a Free site beyond its quota (over_quota in flags). Reporting them gives invalid-input-event. An event of the signed lane cannot be reported here.
  • The mode has a daily cap of its own, per site (one twentieth of the verifications of the day, 20 at least; two hypotheses, to be reviewed during the pilot), counted apart from that of the signed kit. Beyond it: 422. The cap does not let the outcome of every verification through: send first what teaches the most, failure and account_confirmed_fraud, then success if there is room left.
  • The secret is the one of siteverify: a stolen secret would allow the measurement of your events of the last 30 days to be skewed, and the cap of the day of this mode to be used up, so your real outcomes to be blocked. It changes no verdict and reads nothing. Replacing it in the dashboard closes it.
  • Call the route from your server, after answering the visitor, with a timeout of 3 seconds and without following a redirect; a single retry, later, on a network error, a 429 or a 5xx.

Limits

  • The route has its own rate caps, per address and per key. Beyond them: 429 and Retry-After. This status can be caused by volume: see above.
  • Test secrets share 1,000 calls a day for the whole organisation, all sites together (UTC day). Beyond: 429 rate-limited, with a Retry-After that runs until the next midnight UTC. They also share, to date, 2,500 calls a month (UTC month): beyond, the same 429, whose Retry-After runs until the first of the next UTC month. See test keys.
  • A body of more than 16 KiB, or a response of more than 2048 characters, receives the response of a forged token. Do not send it: check the token before the call.