Developer guideServer check

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.

HTTP
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…
FieldWhereValue
captcha_idqueryYour site key, the captcha_id of the pass.
lot_numberbodyFrom the pass, unchanged.
captcha_outputbodyFrom the pass, unchanged.
pass_tokenbodyFrom the pass, unchanged.
gen_timebodyFrom the pass, unchanged.
sign_tokenbodyHMAC-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
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"}
CodeReasonMeaning
-50102invalid captcha IDThe site key in the query is not one we know.
-50307invalid payload parametersOne 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.
-50002unauthorized encryptionsign_token does not match: wrong secret, signed the wrong field, or uppercase hex.
-50306invalid process tokenpass_token was not issued for these values, or the pass is an offline pass made by a browser that could not reach us.
-50308expired payloadgen_time is more than five minutes in the past, or more than a minute in the future.
-50003repeated verificationThis 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.

StatusWhy
503The 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.
405The method was not POST.
403The endpoint has an address allow-list and your server's address is not on it. Tell us your egress addresses.
timeout, connection errorOur 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

Python 3
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

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

PHP 8
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.

Shell
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"}