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.
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.
Before you begin
Section titled Before you begin- 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 namedWICKET_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.
Call the verify address
Section titled Call the verify addressSend 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.
curl https://api.coritan.com/api/v1/wicket/siteverify \
--data-urlencode "secret=$WICKET_SECRET_KEY" \
--data-urlencode "response=$TOKEN"
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(", "));
}
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"]))
$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.");
}
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)
}
Read the answer
Section titled Read the answerEvery answer is HTTP 200 with a JSON body, except when a budget is spent (below). A token that passed gives:
{
"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:
{"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-actionanddata-cdata, or an empty string when it set none. metadata.interactivetruewhen 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
hostnamewith 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
actionon 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.
Error codes
Section titled Error codes| 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.
Repeat a call safely
Section titled Repeat a call safelyWhen 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.
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 beA 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:
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. labelbotwhen the check let a bot through, orhumanwhen 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.
Result
Section titled ResultYour 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).
Troubleshooting
Section titled Troubleshooting- 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_keywhen 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.
Related
Section titled RelatedNext steps
Section titled Next steps- Test keys to test your server with a passing, a failing and a spent token.
- Read a widget's analytics to see your verified tokens.
- Troubleshoot Wicket if a visitor cannot pass.
API operations on this page
| Method | Path | What it does |
|---|---|---|
POST | /api/v1/wicket/siteverify | Check a token the widget put in your form |
POST | /api/v1/wicket/feedback | Tell Wicket what a passed check turned out to be |