# 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.

Source: https://www.coritan.com/docs/wicket/verify-tokens/

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

- You need a widget and its secret key, which starts with `wks_` ([Add a widget](/docs/wicket/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](/docs/wicket/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

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](#repeat-a-call-safely) says what it does.

```bash title="Verify a token with curl"
curl https://api.coritan.com/api/v1/wicket/siteverify \
  --data-urlencode "secret=$WICKET_SECRET_KEY" \
  --data-urlencode "response=$TOKEN"
```

```js title="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 title="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 title="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 title="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)
}
```

## Read the answer

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](#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.

## 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

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.

```bash
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

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:

```bash title="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.

## Result

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](/docs/wicket/widget-analytics/#tokens-verified)).

## 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_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.

## Related

- [How Wicket works](/docs/wicket/how-wicket-works/)
- [Install a widget](/docs/wicket/install-a-widget/)
- [Manage widgets with the API](/docs/wicket/manage-widgets-with-the-api/)
- [Wicket limits](/docs/wicket/limits/)

## Next steps

- [Test keys](/docs/wicket/test-keys/) to test your server with a passing, a failing and a spent token.
- [Read a widget's analytics](/docs/wicket/widget-analytics/#tokens-verified) to see your verified tokens.
- [Troubleshoot Wicket](/docs/wicket/troubleshooting/) if a visitor cannot pass.

## API

- `POST /api/v1/wicket/siteverify`: Check a token the widget put in your form (https://www.coritan.com/docs/api/reference/client/wicket/#op-post-api-v1-wicket-siteverify)
- `POST /api/v1/wicket/feedback`: Tell Wicket what a passed check turned out to be (https://www.coritan.com/docs/api/reference/client/wicket/#op-post-api-v1-wicket-feedback)
