Skip to content

Server-side verification

By adding the client side code, you were able to render a Procaptcha widget that identified if users were real people or automated bots. When the captcha succeeded, the Procaptcha script inserted unique data into your form data, which is then sent to your server for verification. The are currently two options for verifying the user’s response server side:

To verify that the token is indeed real and valid, you must now verify it at the API endpoint:

https://api.prosopo.io/siteverify

EU-only and US-only endpoints are also available if you prefer your data to be processed in a specific region:

The endpoint expects a POST request with the procaptcha-response token. You must also pass your secret key, which you can obtain by logging in to our customer portal.

A simple test will look like this, where the contents in data is the procaptcha-response token, after being parsed:

// pseudocode
// get the contents of the procaptcha-response token
data = req.body['procaptcha-response']
// send a POST application/json request to the API endpoint
response = POST('https://api.prosopo.io/siteverify', {
token: data.token,
secret: 'your_secret_key',
})

Or, as a CURL command:

Terminal window
curl --location 'https://api.prosopo.io/siteverify' \
--header 'Content-Type: application/json' \
--data '{"secret":"your secret key copied from within the customer portal","token":"PROCAPTCHA-RESPONSE"}'

Note that the endpoint expects the application/json Content-Type. You can see exactly what is sent using

Terminal window
curl -vv

in the example above. The response will be a JSON object with a verified key, which will be true if the token is valid and false if it is not.

{
"status": "ok",
"verified": true
}

Professional and Enterprise Tier accounts also receive a risk score associated with the request. The closer the score is to 1, the more likely it is that the request is from a bot.

{
"status": "ok",
"verified": true,
"score": 0.1
}

Professional and Enterprise Tier can pass the user’s IP address to the verification endpoint, which will allow Prosopo to perform additional checks on the request. This is optional, but recommended for better accuracy. To do this, include the ip field in your POST data:

{
"secret": "your_secret_key",
"token": "PROCAPTCHA-RESPONSE",
"ip": "USER_IP_ADDRESS"
}

Professional and Enterprise Tier can pass email to the verification endpoint, which will allow Prosopo to filter spam and temporary email addresses. This is optional, but recommended if you are receiving a lot of spam from temporary email domains. To do this, include the email field in your POST data:

{
"secret": "your_secret_key",
"token": "PROCAPTCHA-RESPONSE",
"email": "USER_EMAIL_DOMAIN"
}

Note: You do not need to include the full email address, just the domain is sufficient for Prosopo to perform the necessary checks. For example, if the user’s email is a@example.com then you can pass example.com as the value for the email field.

If you rendered the widget with your own session identifier (see Session correlation), pass the same value here as clientSessionId. The token will then only verify if it was earned in that session:

{
"secret": "your_secret_key",
"token": "PROCAPTCHA-RESPONSE",
"clientSessionId": "YOUR_SESSION_ID"
}

A token whose solution carries a different session id, or none at all (which is what a token solved outside your session looks like), comes back as a 200 with verified: false; Professional and Enterprise Tier responses name the cause with reason: "API.CLIENT_SESSION_MISMATCH". This is what stops a token being lifted from one browser and submitted from another.

Both halves are required for the check to happen. If you send clientSessionId here but did not render the widget with one, every verification will fail; if you rendered with one but omit it here, no check is performed and the token verifies as normal.

Note: the field is clientSessionId, not sessionId. Verification responses may contain a sessionId field, which is Prosopo’s own frictionless session and is unrelated to yours.

const fetch = require('node-fetch');
async function verifyToken(token) {
const response = await fetch('https://api.prosopo.io/siteverify', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({secret: 'your_secret_key', token}),
});
const data = await response.json();
return data.verified || false; // Return verified field, default to false
}
<?php
function verifyToken($token) {
$url = 'https://api.prosopo.io/siteverify';
$data = json_encode(["secret" => "your_secret_key", "token" => $token]);
$options = [
'http' => [
'header' => "Content-Type: application/json\r\n",
'method' => 'POST',
'content' => $data,
],
];
$context = stream_context_create($options);
$result = file_get_contents($url, false, $context);
$response = json_decode($result, true);
return $response["verified"] ?? false; // Return verified field, default to false
}
?>
import requests
def verify_token(token):
url = "https://api.prosopo.io/siteverify"
data = {"secret": "your_secret_key", "token": token}
response = requests.post(url, json=data)
return response.json().get("verified", False) # Return verified field, default to False
import java.io.*;
import java.net.*;
import org.json.JSONObject;
public class ProcaptchaVerification {
public static boolean verifyToken(String token) throws Exception {
URL url = new URL("https://api.prosopo.io/siteverify");
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("POST");
con.setRequestProperty("Content-Type", "application/json");
con.setDoOutput(true);
String jsonInputString = "{\"secret\":\"your_secret_key\", \"token\":\"" + token + "\"}";
try (OutputStream os = con.getOutputStream()) {
byte[] input = jsonInputString.getBytes("utf-8");
os.write(input, 0, input.length);
}
BufferedReader br = new BufferedReader(new InputStreamReader(con.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
// Parse JSON response
JSONObject jsonResponse = new JSONObject(response.toString());
return jsonResponse.optBoolean("verified", false); // Default to false if not found
}
}

We have a JavaScript implementation of the Procaptcha verification package available on npm.

JavaScript / TypeScript Verification

Section titled JavaScript / TypeScript Verification

The @prosopo/server package is available on NPM and can be installed using:

Terminal window
npm install @prosopo/server

To verify a user’s response using JavaScript / TypeScript, simpy import the verify function from @prosopo/server and pass it the procaptcha-response POST data. Types can be imported from @prosopo/types.

import {ProsopoServer, getServerConfig} from '@prosopo/server'
import {getPair} from '@prosopo/keyring'
import {ApiParams} from '@prosopo/types'
...
// parse the body received from the frontend
const payload = JSON.parse(event.body)
// parse the procaptcha response token
const procaptchaResponse = payload[ApiParams.procaptchaResponse]
// initialise the `ProsopoServer` class
const config = getServerConfig()
const pair = getPair(process.env.PROSOPO_SITE_PRIVATE_KEY, config.account.address)
const prosopoServer = new ProsopoServer(config, pair)
// check if the captcha response is verified
const result = await prosopoServer.isVerified(procaptchaResponse)
// or, to correlate the session the captcha was solved in:
// isVerified(token, ip?, email?, clientSessionId?)
// const result = await prosopoServer.isVerified(procaptchaResponse, undefined, undefined, sessionId)
if (result.verified) {
// perform CAPTCHA protected action
}

There is an example TypeScript server NodeJS Server Side Example that you run locally.

All /siteverify responses use a uniform JSON envelope. For programmatic handling, branch on error.key (a stable, machine-readable identifier) rather than error.message (human-readable, and not guaranteed to stay word-for-word stable between releases).

A successful response:

{
"status": "ok",
"verified": true,
"score": 0.1
}

status is always the string "ok" on a 200. score appears on Professional and Enterprise Tier accounts when a score was computed. On a verified: false response, Professional and Enterprise Tier accounts may additionally receive a reason field naming why the token was rejected; see Failure reasons. Free Tier responses carry neither field.

An error response:

{
"error": {
"code": 400,
"key": "API.INVALID_SITE_KEY",
"message": "Invalid site key"
}
}

error.code always matches the HTTP status. For API.INVALID_BODY, message is an array of field-level validation issues rather than a single string.

HTTP 200: success and soft failures

Section titled HTTP 200: success and soft failures

A 200 response does not mean the user passed the challenge. Always inspect the verified field.

ScenarioBodyNotes
Token verified{ "status": "ok", "verified": true }The happy path. Paid tiers also receive score.
Token cannot be decoded{ "status": "ok", "verified": false }The token is malformed or forged. Treat as a failed challenge; it will never verify.
Token failed verification{ "status": "ok", "verified": false }The provider rejected the solve. Paid tiers also receive reason; see Failure reasons.
Token expired{ "status": "ok", "verified": false }Tokens are valid for a limited window after the solve: by default 3 minutes for PoW and puzzle, 15 minutes for image. Verify promptly when the form arrives.
Token already verified{ "status": "ok", "verified": false }Tokens are single use. A second /siteverify call with the same token always fails; the usual cause is a double form submission. Paid tiers see reason: "API.USER_ALREADY_VERIFIED".
Session id mismatch{ "status": "ok", "verified": false }You sent clientSessionId and the token was not earned in that session. Treat as a failed challenge; paid tiers see reason: "API.CLIENT_SESSION_MISMATCH". See Session ID.
Reserved test site key{ "status": "ok", "verified": true } or falseA reserved test site key short-circuits verification and returns its forced verdict before the secret is even read.
Provider verification timed out{ "status": "ok", "verified": true }Fail-open: if the verification provider does not answer within 5 seconds, the request is allowed through so legitimate users are not blocked during an incident.

The fail-open case is indistinguishable from a normal pass on the Free Tier. On paid tiers, a verified: true response with no score where you normally receive one is the hint.

On the Professional and Enterprise Tiers, a verified: false response may carry a reason identifying the rejection. The values are stable identifiers, grouped below by what they say about the request.

The token itself:

reasonMeaning
API.USER_NOT_VERIFIEDGeneric failure, including an incorrectly solved challenge.
API.USER_ALREADY_VERIFIEDThe token was already verified once. Tokens are single use.
API.USER_NOT_VERIFIED_NO_SOLUTIONThe token names no solution, or the provider holds no solution under that id.
API.USER_NOT_VERIFIED_TIME_EXPIREDThe solve is older than the cached-verification window for its captcha type.
API.TIMESTAMP_TOO_OLDToo much time passed between the solve and your verify call.
API.CLIENT_SESSION_MISMATCHThe clientSessionId you passed does not match the session the widget was rendered with.
CAPTCHA.DAPP_USER_SOLUTION_NOT_FOUNDThe provider has no record of the challenge in this token.
CAPTCHA.INVALID_SOLUTIONThe challenge was answered incorrectly.
CAPTCHA.INVALID_SALTThe solve carried a salt the provider could not decode.

The visitor:

reasonMeaning
API.ACCESS_POLICY_BLOCKAn access control rule on your site blocked this visitor.
API.FAILED_IP_VALIDATIONThe ip you passed failed validation against the IP that solved the captcha (applies when IP validation rules are enabled for the site).
API.DISALLOWED_WEBVIEWThe solve came from an in-app WebView and your site settings disallow WebViews.
CAPTCHA.DECISION_MACHINE_DENIEDA custom decision rule on your account denied the request.

Your traffic filter settings, each naming the IP category that was rejected:

reasonMeaning
API.VPN_BLOCKEDA VPN connection.
API.PROXY_BLOCKEDA proxy server.
API.TOR_BLOCKEDA Tor exit node.
API.DATACENTER_BLOCKEDA datacenter IP address.
API.ABUSER_BLOCKEDA network flagged for abuse.
API.CRAWLER_BLOCKEDA known crawler.
API.MOBILE_BLOCKEDA mobile network connection.
API.SATELLITE_BLOCKEDA satellite connection.

The email you passed, when spam filtering is enabled:

reasonMeaning
API.SPAM_EMAIL_DOMAINA known disposable or blocked domain.
API.SPAM_EMAIL_RULEOne of your custom email rules matched.
API.SPAM_EMAIL_COUNT_EXCEEDEDToo many submissions from this address.

These are the values you are likely to meet. Treat reason as an open set: branch on the ones you handle explicitly and fall back to “challenge failed” for anything else, so a new value never breaks your integration.

HTTP 4xx: client and integration errors

Section titled HTTP 4xx: client and integration errors

These indicate a problem with the request itself. Retrying an unchanged request will not succeed, with the single exception of 429.

HTTPerror.keyTriggerWhat to fix
400API.MISSING_BODYThe request has no body and no query parameters.Send a POST with a JSON body containing at least secret and token.
400API.PARSE_ERRORThe body could not be parsed at all (invalid JSON, or a malformed form body).Send valid JSON with the application/json Content-Type.
400API.INVALID_BODYThe body parsed but failed validation: a required field is missing or has the wrong type. error.message lists the individual issues.Check field names and types: token (string), secret (string), and the optional ip, email, clientSessionId.
400GENERAL.MISSING_SECRET_KEYThe secret is missing, or is not in a recognisable secret key format.Copy the secret key exactly from the customer portal.
400API.INVALID_SITE_KEYThe secret is a well-formed key but does not correspond to the site key inside the token.Confirm the secret belongs to the same site as the widget that produced the token. The common cause is a secret copied from a different site. The check is skipped for reserved test site keys, which have no secret.
400(forwarded)The verification provider rejected the token with a 400 of its own; its error.key is passed through.See Forwarded provider errors.
429API.BAD_REQUESTThe provider rate-limited verify calls for your site key. The generic key is expected here; the HTTP status is the signal.Back off and retry.

An uncaught exception inside /siteverify that is not one of the cases above defaults to a 400 with key: "API.UNKNOWN".

HTTP 5xx: server and upstream errors

Section titled HTTP 5xx: server and upstream errors

These indicate a transient or upstream problem. Retrying with backoff is appropriate.

HTTPerror.keyTrigger
500API.BAD_REQUESTThe verification provider could not be reached, or failed internally while verifying. Despite the key’s name, this is a server-side failure, not a problem with your request.
(forwarded)variesAny other provider error is passed through with its original HTTP status and error.key.

When the verification provider itself rejects the request, /siteverify forwards the provider’s HTTP status and error.key. The keys you may see:

error.keyHTTPMeaning
API.SITE_KEY_NOT_REGISTERED400The site key inside the token is well-formed but not registered with the provider. Re-check the site key in the customer portal.
CAPTCHA.PARSE_ERROR400The verify request the provider received was malformed. Not expected when calling /siteverify; it can occur when integrating against a provider’s verify endpoints directly.
API.BAD_REQUEST500The provider hit an unexpected error while verifying (an invalid token payload, a failed signature check, or a provider-side fault).
API.UNKNOWN_ERRORvariesThe provider returned an error block without a recognised key. Inspect error.message and contact support if it persists.
  1. Check verified first. A 200 with verified: false is the normal “challenge failed” path, not an exception.
  2. Retry only on 429 and 5xx. Other 4xx responses indicate a configuration or integration error and will not succeed on retry.
  3. Log error.key, not error.message. Keys are stable identifiers; message wording can change (and is an array for API.INVALID_BODY).
  4. Verify each token once, promptly. Tokens are single use and time-limited, so verify as soon as the form arrives and never re-verify on retry logic of your own.
  5. Watch for the fail-open pattern. On paid tiers, a sustained run of verified: true responses that are missing the usual score field suggests provider verification is timing out and failing open. Treat it as an incident signal rather than a run of successful verifications.