Skip to content
Coritan Docs

Verify a token on your server

Send the token a Wicket widget put in your form to the verify address, read the answer, and report later whether a check was a person or a bot.

View as Markdown

The widget on your page puts a token in your form, but a script can send a form without ever loading the widget. Your server therefore asks Wicket whether the token is real before it accepts the form. The call is the same one you make to Cloudflare Turnstile, with a different address and the field name wicket-response.

  • You need a widget and its secret key, which starts with wks_ (Add a widget). We show the secret key once, so keep it in your server's settings, for example in an environment variable named WICKET_SECRET_KEY. Never put it in a page or a repository.
  • The widget must be on your page, inside the form (Add Wicket to your site). The browser sends the token in the field wicket-response.
  • The call needs no access token. The secret key in the body is its only credential.

Send a POST to https://api.coritan.com/api/v1/wicket/siteverify from your server, once for each form you receive. The body can be form-encoded, multipart form data or JSON.

secret
The widget's secret key. Required.
response
The token from the form field wicket-response. Required, and at most 2048 characters.
remoteip
The visitor's IP address as your server sees it. Optional. When it is in a different network from the one that solved the check, we note it with the verification. It never changes the answer.
idempotency_key
A value you choose for this verification, such as a UUID. Optional, and at most 64 characters. Repeat a call safely says what it does.
Verify a token with curl
curl https://api.coritan.com/api/v1/wicket/siteverify \
  --data-urlencode "secret=$WICKET_SECRET_KEY" \
  --data-urlencode "response=$TOKEN"
Node
const res = await fetch("https://api.coritan.com/api/v1/wicket/siteverify", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ secret: process.env.WICKET_SECRET_KEY, response: token, remoteip: visitorIp }),
});
const outcome = await res.json();
if (!outcome.success || outcome.hostname !== "example.com") {
  throw new Error("The check failed: " + outcome["error-codes"].join(", "));
}
Python
import os
import requests

res = requests.post(
    "https://api.coritan.com/api/v1/wicket/siteverify",
    data={"secret": os.environ["WICKET_SECRET_KEY"], "response": token, "remoteip": visitor_ip},
    timeout=10,
)
outcome = res.json()
if not outcome["success"] or outcome["hostname"] != "example.com":
    raise PermissionError("The check failed: " + ", ".join(outcome["error-codes"]))
PHP
$ch = curl_init("https://api.coritan.com/api/v1/wicket/siteverify");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        "secret" => getenv("WICKET_SECRET_KEY"),
        "response" => $_POST["wicket-response"] ?? "",
        "remoteip" => $_SERVER["REMOTE_ADDR"],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 10,
]);
$outcome = json_decode(curl_exec($ch), true);
if (!$outcome["success"] || $outcome["hostname"] !== "example.com") {
    http_response_code(403);
    exit("The check failed.");
}
Go
form := url.Values{
    "secret":   {os.Getenv("WICKET_SECRET_KEY")},
    "response": {token},
    "remoteip": {visitorIP},
}
res, err := http.PostForm("https://api.coritan.com/api/v1/wicket/siteverify", form)
if err != nil {
    return err
}
defer res.Body.Close()
var outcome struct {
    Success    bool     `json:"success"`
    Hostname   string   `json:"hostname"`
    ErrorCodes []string `json:"error-codes"`
}
if err := json.NewDecoder(res.Body).Decode(&outcome); err != nil {
    return err
}
if !outcome.Success || outcome.Hostname != "example.com" {
    return fmt.Errorf("the check failed: %v", outcome.ErrorCodes)
}

Every answer is HTTP 200 with a JSON body, except when a budget is spent (below). A token that passed gives:

JSON
{
  "success": true,
  "challenge_ts": "2026-10-17T15:14:30.000Z",
  "hostname": "example.com",
  "error-codes": [],
  "action": "login",
  "cdata": "sessionid-123",
  "metadata": {"interactive": false, "event": "9f78e0ed210960d7693b167e5a1c0d2b"}
}

A token that did not pass gives only success and the codes:

JSON
{"success": false, "error-codes": ["invalid-input-response"]}
challenge_ts
When the visitor passed the check, in UTC.
hostname
The hostname of the page that showed the widget.
action, cdata
What your page set on the widget with data-action and data-cdata, or an empty string when it set none.
metadata.interactive
true when the visitor ticked the checkbox to pass.
metadata.event
The id of this check, 32 hex characters. Report what a check turned out to be uses it.

Refuse the form when success is false. A token proves that a visitor passed on a page that shows your widget, so also check what the answer says about the form:

  • Compare hostname with the hostname you expect. A widget works on every hostname it lists, so this is how you tell your checkout page from your blog.
  • When you set an action on the widget, compare it with the action this form expects. A token from your sign-up page then cannot be used on your password reset form.
Code When
missing-input-secret The request had no secret.
invalid-input-secret No widget has that secret key, or the widget is turned off. A secret key you rotated keeps working until the end of the grace period you chose.
missing-input-response The request had no response. When both are missing, the list holds both codes.
invalid-input-response The token cannot be opened, belongs to another widget, is longer than 2048 characters, or is a dummy token sent with a real secret key.
bad-request We could not read the body, it is larger than 64 KB, or idempotency_key is longer than 64 characters.
timeout-or-duplicate The token is older than 300 seconds, or you verified it before.
internal-error We could not record the verification. Send the call again.
rate-limited The call answers HTTP 429. The widget used its budget of verify calls for the minute, which is 6000 by default, or one address sent 600 calls a minute with secret keys that belong to no widget. Wait a minute.

A token works once and for 300 seconds. When a visitor takes longer, the widget gets a new token by itself.

When a network error leaves you not knowing whether a verification went through, you cannot send the token again: the second call answers timeout-or-duplicate. Send an idempotency_key with the first call and repeat it unchanged, and the repeat returns the first answer instead.

Shell
curl https://api.coritan.com/api/v1/wicket/siteverify \
  --data-urlencode "secret=$WICKET_SECRET_KEY" \
  --data-urlencode "response=$TOKEN" \
  --data-urlencode "idempotency_key=3f1b7a90-1c2d-4e5f-8a9b-0c1d2e3f4a5b"

A different idempotency_key, or none, on a token you already verified answers timeout-or-duplicate. Only a verification that succeeded is kept for a repeat.

Report what a check turned out to be

Section titled Report what a check turned out to be

A check can be wrong in both directions. A bot can pass, and a person can fail. When you learn which, store metadata.event with the record the form made (an account, an order) and report it later. Reports help us improve the check, and our team reviews them.

Send a POST to https://api.coritan.com/api/v1/wicket/feedback as JSON:

Report a bot
curl https://api.coritan.com/api/v1/wicket/feedback \
  -H "Content-Type: application/json" \
  -d "{\"secret\": \"$WICKET_SECRET_KEY\", \"event\": \"9f78e0ed210960d7693b167e5a1c0d2b\", \"label\": \"bot\", \"note\": \"Spam account\"}"
secret
The widget's secret key.
event
The 32 hex character id from metadata.event. It must be a check of this widget.
label
bot when the check let a bot through, or human when it doubted a person.
note
A short note for our team. Optional, and at most 500 characters.

The answer is HTTP 202 with {"status": "received"}. Your report counts as confirmed from the start. A secret key that no widget has answers 403 with invalid_secret, a label or event we cannot accept answers 422, and a widget that used its budget for the minute answers 429.

Your server accepts a form when the answer says success: true and the hostname and action are the ones you expect, and refuses it otherwise. The widget's Analytics tab counts each verified token (Read a widget's analytics).

The answer is invalid-input-secret
The secret key is not a widget's, or you rotated it with Stop the old key now. Put the current secret key in WICKET_SECRET_KEY. A widget that our team turned off has no working secret key until it is turned on again.
The answer is invalid-input-response
The token belongs to another widget, or it is WICKET.DUMMY.TOKEN, which a test site key's widget hands back. Use the widget's own site key on the page, and the secret key of the same widget on your server.
The answer is timeout-or-duplicate
Your server verified the same token twice, or took more than 300 seconds. Verify once for each submission, and use an idempotency_key when you retry.
The answer succeeds, but the hostname is not yours
The token came from a page that shows your widget on a hostname the widget lists. Refuse the form, or remove the hostname from the widget's settings if it should not be there.

API operations on this page

MethodPathWhat it does
POST/api/v1/wicket/siteverifyCheck a token the widget put in your form
POST/api/v1/wicket/feedbackTell Wicket what a passed check turned out to be