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

  1. Request a challenge: POST /api/v1/m2m/challenge with siteKey + secretKey.
  2. Solve the PoW: find a nonce where SHA-256(salt + ":" + nonce) has the required number of leading zero hex characters (difficulty).
  3. Submit the solution: POST /api/v1/m2m/verify with siteKey, secretKey, challengeId, nonce, and the resulting hash.
  4. 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.

challenge.sh
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.

verify.sh
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

m2m.mjs
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

m2m.py
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/verify

PHP

m2m.php
<?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#)

M2M.cs
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/verify

Verify Response

response.json
{
  "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).

errors.json
{ "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.