Server check
Confirm a pass from your server: the request, how to sign it, every answer and code, retries and timeouts.
The request
One POST to https://verify.otcaptcha.com/validate, from your server, never from the browser. The site key goes in the query string; the five strings of the pass go in a form-encoded body, with your signature as a sixth field. Fields in the query string are not read, and a JSON body is not read either: use application/x-www-form-urlencoded.
POST /validate?captcha_id=[SITE KEY] HTTP/1.1
Host: verify.otcaptcha.com
Content-Type: application/x-www-form-urlencoded
lot_number=3f9c2b6e4d1a4f0b9c8e7d6a5b4c3d2e&captcha_output=eyJ0Ijoibm9ybWFsIiwicyI6MH0&pass_token=9a1f…&gen_time=1793955600&sign_token=7c3e…| Field | Where | Value |
|---|---|---|
captcha_id | query | Your site key, the captcha_id of the pass. |
lot_number | body | From the pass, unchanged. |
captcha_output | body | From the pass, unchanged. |
pass_token | body | From the pass, unchanged. |
gen_time | body | From the pass, unchanged. |
sign_token | body | HMAC-SHA256 of lot_number with your secret as the key, as 64 lowercase hex characters. |
The body must be under 8 KB and arrive within two seconds of the connection, which any direct call does. There is no authentication header: the signature proves the call comes from a holder of the secret.
The answers
Every decision is an HTTP 200 with a JSON body. result is success or fail. A failure carries a reason for your logs and a code, always a negative number sent as a JSON string. Only a success lets the form through.
HTTP/1.1 200 OK
Content-Type: application/json
{"result":"success","reason":"","captcha_args":{}}
HTTP/1.1 200 OK
Content-Type: application/json
{"result":"fail","reason":"expired payload","code":"-50308"}| Code | Reason | Meaning |
|---|---|---|
-50102 | invalid captcha ID | The site key in the query is not one we know. |
-50307 | invalid payload parameters | One of the four pass fields is missing or empty, gen_time is not a whole number, or the body could not be parsed as a form. |
-50002 | unauthorized encryption | sign_token does not match: wrong secret, signed the wrong field, or uppercase hex. |
-50306 | invalid process token | pass_token was not issued for these values, or the pass is an offline pass made by a browser that could not reach us. |
-50308 | expired payload | gen_time is more than five minutes in the past, or more than a minute in the future. |
-50003 | repeated verification | This pass has been checked before. See retries below. |
The checks run in the order of the table, and the first failure is the one reported: a reused pass sent with a bad signature answers -50002, not -50003. On success, reason is empty and captcha_args is an empty object.
When there is no decision
A status other than 200 means we did not decide, and it never means the pass was bad. Your outage policy says what your server does then.
| Status | Why |
|---|---|
503 | The store that records spent passes could not be reached, the instance is at its concurrency limit, or the body took more than two seconds to arrive. |
405 | The method was not POST. |
403 | The endpoint has an address allow-list and your server's address is not on it. Tell us your egress addresses. |
| timeout, connection error | Our service or the path to it is down. |
Use a timeout of about five seconds for the whole call. A retry after a network error is safe within the window described next.
Once, with one retry
A pass is spent by the first call that reaches a decision. An identical call for the same pass within 20 seconds of the first is treated as a retry of that call and gets the same answer, once. A third call, a call after the window, or a call for the same lot_number with different values answers -50003.
So a client that times out and resends is safe, and a form that is submitted twice is refused the second time. Confirm the pass as soon as it reaches your server, and never store it for a later check: it expires five minutes after gen_time.
Samples
Each sample signs, posts, treats a non-200 as "no decision" and returns whether the pass is good along with the code. A Node.js sample is on the overview.
Python
import hashlib, hmac, json, urllib.parse, urllib.request
def confirm_pass(p, secret):
sign_token = hmac.new(secret.encode(), p["lot_number"].encode(), hashlib.sha256).hexdigest()
body = urllib.parse.urlencode({
"lot_number": p["lot_number"],
"captcha_output": p["captcha_output"],
"pass_token": p["pass_token"],
"gen_time": p["gen_time"],
"sign_token": sign_token,
}).encode()
url = "https://verify.otcaptcha.com/validate?captcha_id=" + urllib.parse.quote(p["captcha_id"])
req = urllib.request.Request(url, data=body, method="POST")
with urllib.request.urlopen(req, timeout=5) as res: # raises on a non-200: no decision, see Outage policy
answer = json.load(res)
return answer["result"] == "success", answer.get("code", "")Go
func confirmPass(ctx context.Context, p Pass, secret string) (ok bool, code string, err error) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(p.LotNumber))
form := url.Values{
"lot_number": {p.LotNumber},
"captcha_output": {p.CaptchaOutput},
"pass_token": {p.PassToken},
"gen_time": {p.GenTime},
"sign_token": {hex.EncodeToString(mac.Sum(nil))},
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://verify.otcaptcha.com/validate?captcha_id="+url.QueryEscape(p.CaptchaID),
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)
if err != nil {
return false, "", err // no decision: see Outage policy
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return false, "", fmt.Errorf("no decision: HTTP %d", res.StatusCode)
}
var a struct{ Result, Code string }
if err := json.NewDecoder(res.Body).Decode(&a); err != nil {
return false, "", err
}
return a.Result == "success", a.Code, nil
}PHP
function confirmPass(array $p, string $secret): array {
$signToken = hash_hmac('sha256', $p['lot_number'], $secret);
$body = http_build_query([
'lot_number' => $p['lot_number'],
'captcha_output' => $p['captcha_output'],
'pass_token' => $p['pass_token'],
'gen_time' => $p['gen_time'],
'sign_token' => $signToken,
]);
$url = 'https://verify.otcaptcha.com/validate?captcha_id=' . rawurlencode($p['captcha_id']);
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => $body,
'timeout' => 5,
'ignore_errors' => true,
]]);
$raw = file_get_contents($url, false, $context);
if ($raw === false || !str_contains($http_response_header[0], ' 200 ')) {
throw new RuntimeException('no decision'); // see Outage policy
}
$answer = json_decode($raw, true);
return [$answer['result'] === 'success', $answer['code'] ?? ''];
}A first call with curl
A correctly signed call with a made-up token answers a decision, which proves your server can reach the endpoint, that your secret signs correctly, and shows the response shape. Replace the token with a real pass from a browser and the same command answers success.
LOT=3f9c2b6e4d1a4f0b9c8e7d6a5b4c3d2e
SIGN=$(printf '%s' "$LOT" | openssl dgst -sha256 -hmac '[SECRET]' | sed 's/.* //')
curl -s -X POST 'https://verify.otcaptcha.com/validate?captcha_id=[SITE KEY]' \
--data-urlencode "lot_number=$LOT" \
--data-urlencode 'captcha_output=x' \
--data-urlencode 'pass_token=x' \
--data-urlencode 'gen_time=1' \
--data-urlencode "sign_token=$SIGN"
# {"result":"fail","reason":"invalid process token","code":"-50306"}