Machine-to-Machine (M2M) API
Issue and verify gkCAPTCHA tokens for non-browser clients -- CLIs, backend services, CI jobs, and other automated callers -- using a secret-key-authenticated proof-of-work handshake.
M2M requires a Starter plan or higher and must be enabled per-site in your dashboard. Authentication uses your site's secret key, so M2M calls must originate from a trusted server -- never ship the secret key to a browser or mobile app.
How It Works
Unlike the browser widget, M2M clients have no behavioral signals to collect. Instead they prove they are willing to spend CPU by solving a proof-of-work (PoW) challenge at a fixed difficulty. The flow is two requests: (1) request a challenge with your site key + secret key, (2) solve the PoW locally and submit the solution to receive a short-lived JWT. Validate that JWT from your backend exactly as you would a browser token, via /api/v1/token/verify.
Verification Flow
- Request a challenge: POST /api/v1/m2m/challenge with siteKey + secretKey.
- Solve the PoW: find a nonce where SHA-256(salt + ":" + nonce) has the required number of leading zero hex characters (difficulty).
- Submit the solution: POST /api/v1/m2m/verify with siteKey, secretKey, challengeId, nonce, and the resulting hash.
- Receive a JWT token (type: m2m). Validate it server-side via POST /api/v1/token/verify before trusting the caller.
1. Request a Challenge
Authenticate with your site key and secret key. The response contains the salt, a fixed difficulty (6), and an expiry. Challenges are single-use and expire quickly, so solve and submit promptly.
curl -X POST https://gkcaptcha.gatekeeper.sa/api/v1/m2m/challenge \
-H "Content-Type: application/json" \
-d '{
"siteKey": "pk_live_abc123",
"secretKey": "sk_live_xyz789"
}'
# Response (200):
# {
# "challengeId": "a1b2c3d4...",
# "salt": "7f8e9d0c...",
# "nonce": "server-seed",
# "difficulty": 6,
# "expiresAt": 1700000060
# }2. Solve and Submit
Iterate nonces until SHA-256(salt + ":" + nonce) begins with `difficulty` leading zero hex characters, then submit. On success you receive a JWT valid for 5 minutes.
curl -X POST https://gkcaptcha.gatekeeper.sa/api/v1/m2m/verify \
-H "Content-Type: application/json" \
-d '{
"siteKey": "pk_live_abc123",
"secretKey": "sk_live_xyz789",
"challengeId": "a1b2c3d4...",
"nonce": 847291,
"hash": "000000e7f8d9c..."
}'
# Response (200):
# {
# "success": true,
# "token": "eyJhbGciOiJIUzI1NiIs...",
# "expiresAt": 1700000360
# }Code Examples
Complete request -> solve -> submit examples in five languages. Each computes the PoW with the standard library only -- no gkCAPTCHA SDK is required for M2M.
Node.js
import crypto from "node:crypto";
const BASE = "https://gkcaptcha.gatekeeper.sa";
const SITE_KEY = process.env.GKCAPTCHA_SITE_KEY; // pk_live_...
const SECRET_KEY = process.env.GKCAPTCHA_SECRET_KEY; // sk_live_...
// PoW: find a nonce where SHA-256(salt + ":" + nonce) has `difficulty`
// leading zero hex characters.
function solve(salt, difficulty) {
const prefix = "0".repeat(difficulty);
for (let nonce = 0; ; nonce++) {
const hash = crypto.createHash("sha256")
.update(`${salt}:${nonce}`)
.digest("hex");
if (hash.startsWith(prefix)) return { nonce, hash };
}
}
export async function getM2MToken() {
// 1. Request a challenge
const chRes = await fetch(`${BASE}/api/v1/m2m/challenge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siteKey: SITE_KEY, secretKey: SECRET_KEY }),
});
const ch = await chRes.json();
if (!chRes.ok) throw new Error(`challenge failed: ${ch.error}`);
// 2. Solve the PoW locally
const { nonce, hash } = solve(ch.salt, ch.difficulty);
// 3. Submit the solution
const vRes = await fetch(`${BASE}/api/v1/m2m/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siteKey: SITE_KEY,
secretKey: SECRET_KEY,
challengeId: ch.challengeId,
nonce,
hash,
}),
});
const v = await vRes.json();
if (!v.success) throw new Error(`verify failed: ${v.error}`);
return v.token; // pass to /api/v1/token/verify on your backend
}Python
import hashlib
import os
import httpx
BASE = "https://gkcaptcha.gatekeeper.sa"
SITE_KEY = os.environ["GKCAPTCHA_SITE_KEY"] # pk_live_...
SECRET_KEY = os.environ["GKCAPTCHA_SECRET_KEY"] # sk_live_...
def solve(salt: str, difficulty: int):
"""Find a nonce where SHA-256(salt + ':' + nonce) has `difficulty`
leading zero hex characters."""
prefix = "0" * difficulty
nonce = 0
while True:
digest = hashlib.sha256(f"{salt}:{nonce}".encode()).hexdigest()
if digest.startswith(prefix):
return nonce, digest
nonce += 1
def get_m2m_token() -> str:
with httpx.Client(base_url=BASE) as client:
# 1. Request a challenge
ch = client.post("/api/v1/m2m/challenge", json={
"siteKey": SITE_KEY, "secretKey": SECRET_KEY,
}).raise_for_status().json()
# 2. Solve the PoW locally
nonce, hash_ = solve(ch["salt"], ch["difficulty"])
# 3. Submit the solution
v = client.post("/api/v1/m2m/verify", json={
"siteKey": SITE_KEY,
"secretKey": SECRET_KEY,
"challengeId": ch["challengeId"],
"nonce": nonce,
"hash": hash_,
}).json()
if not v.get("success"):
raise RuntimeError(f"verify failed: {v.get('error')}")
return v["token"] # validate via /api/v1/token/verifyPHP
<?php
// Requires PHP 8+ with curl + hash extensions (both bundled by default).
$base = "https://gkcaptcha.gatekeeper.sa";
$siteKey = getenv("GKCAPTCHA_SITE_KEY"); // pk_live_...
$secretKey = getenv("GKCAPTCHA_SECRET_KEY"); // sk_live_...
function post(string $url, array $body): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true);
}
// PoW: nonce where SHA-256(salt . ":" . nonce) has $difficulty leading zeros.
function solve(string $salt, int $difficulty): array {
$prefix = str_repeat("0", $difficulty);
for ($nonce = 0; ; $nonce++) {
$hash = hash("sha256", "$salt:$nonce");
if (str_starts_with($hash, $prefix)) {
return ["nonce" => $nonce, "hash" => $hash];
}
}
}
// 1. Request a challenge
$chReq = post("$base/api/v1/m2m/challenge", [
"siteKey" => $siteKey, "secretKey" => $secretKey,
]);
// 2. Solve the PoW locally
["nonce" => $nonce, "hash" => $hash] = solve($chReq["salt"], $chReq["difficulty"]);
// 3. Submit the solution
$verify = post("$base/api/v1/m2m/verify", [
"siteKey" => $siteKey,
"secretKey" => $secretKey,
"challengeId" => $chReq["challengeId"],
"nonce" => $nonce,
"hash" => $hash,
]);
if (empty($verify["success"])) {
throw new RuntimeException("verify failed: " . ($verify["error"] ?? "unknown"));
}
$token = $verify["token"]; // validate via /api/v1/token/verify.NET (C#)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
// .NET 8+. Uses only System.Net.Http + System.Security.Cryptography.
var baseUrl = "https://gkcaptcha.gatekeeper.sa";
var siteKey = Environment.GetEnvironmentVariable("GKCAPTCHA_SITE_KEY")!; // pk_live_...
var secretKey = Environment.GetEnvironmentVariable("GKCAPTCHA_SECRET_KEY")!; // sk_live_...
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
async Task<JsonElement> PostAsync(string path, object body)
{
var json = JsonSerializer.Serialize(body);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var res = await http.PostAsync(path, content);
return JsonSerializer.Deserialize<JsonElement>(await res.Content.ReadAsStringAsync());
}
// PoW: nonce where SHA-256(salt + ":" + nonce) has 'difficulty' leading zero hex chars.
(long nonce, string hash) Solve(string salt, int difficulty)
{
var prefix = new string('0', difficulty);
for (long nonce = 0; ; nonce++)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{salt}:{nonce}"));
var hex = Convert.ToHexString(bytes).ToLowerInvariant();
if (hex.StartsWith(prefix)) return (nonce, hex);
}
}
// 1. Request a challenge
var ch = await PostAsync("/api/v1/m2m/challenge", new { siteKey, secretKey });
var salt = ch.GetProperty("salt").GetString()!;
var difficulty = ch.GetProperty("difficulty").GetInt32();
// 2. Solve the PoW locally
var (nonce, hash) = Solve(salt, difficulty);
// 3. Submit the solution
var verify = await PostAsync("/api/v1/m2m/verify", new
{
siteKey,
secretKey,
challengeId = ch.GetProperty("challengeId").GetString(),
nonce,
hash,
});
if (!verify.GetProperty("success").GetBoolean())
throw new Exception($"verify failed: {verify.GetProperty("error").GetString()}");
var token = verify.GetProperty("token").GetString(); // validate via /api/v1/token/verifyVerify Response
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIs...", // JWT (type: "m2m"), valid 5 min
"expiresAt": 1700000360 // Unix expiry of the token
}Error Responses
All M2M errors return a JSON body of the form { "success": false, "error": "<code>" }. Rate-limited responses also include retryAfter (seconds).
{ "success": false, "error": "invalid_key" } // bad siteKey/secretKey (401)
{ "success": false, "error": "m2m_disabled" } // M2M not enabled for plan/site (403)
{ "success": false, "error": "challenge_expired" } // challenge missing or expired (400)
{ "success": false, "error": "invalid_solution" } // PoW hash does not match (400)
{ "success": false, "error": "rate_limited", "retryAfter": 30 } // per-secret-key limit (429)
{ "success": false, "error": "quota_exceeded" } // monthly quota reached (429)M2M traffic is rate-limited per secret key and counts against your monthly quota with no overage buffer. Cache and reuse the returned token across requests within its 5-minute lifetime instead of solving a fresh challenge every call.