Backend integration examples

Keep verification credentials on the backend and verify against the site contract

Keep verification credentials on the backend and verify against the site contract

Keep verification credentials on the backend and verify against the site contract

Keep verification credentials on the backend and verify against the site contract

Keep verification credentials on the backend and verify against the site contract

Keep verification credentials on the backend and verify against the site contract

Captchas.irDeveloper center

HTTP integration examples for HTML, PHP, Python, Node.js and Go Separate the browser key from private backend credentials

Why

Simple to wire up, no security shortcuts

Each side only sees what it needs

For visitors

No image puzzles
Cap is a proof-of-work the browser solves; the visitor only sees a checkbox
Persian or English
Pass lang: "fa" for Persian widget labels; English is the default
No cookies
The widget calls with credentials: "omit" and sets no cookies on your site

Verification

Bound receipts
A receipt is only valid for its site, action, intent and request hash
Single use
A second redemption gets 409 ALREADY_CONSUMED, even under concurrent requests
Fail-closed
An unknown result is never a success, and your backend should not treat it as one either

Integration

Plain HTTP
No dedicated SDK and no extra dependencies; every snippet uses only the language's standard library
Separate host or same origin
Captchas.ir can live on its own subdomain; CORS is only open to registered origins
Readable errors
Every error carries code, message, request_id and retryable
Responsibilities

Who holds what

The boundaries are clear: the public key lives in the browser, the credential on your backend and the engine secrets only inside Captchas.ir

  • Your backend creates the intent and redeems the receipt
  • The browser opens the session and solves the challenge
  • Captchas.ir talks to Cap and issues the receipt

Secrets and keys

Browser

  • Public site key
  • Session completion_token

Your backend

  • dck_… credential
  • request_hash

Captchas.ir only

  • Cap site secret
  • Cap admin key
  • Data key
Steps

Three steps to a protected form

  1. Create a site

    In the panel, create a site with its allowed origins and connect it to Cap

  2. Get your keys

    Create a public key and a backend credential with intents:create and receipts:verify

  3. Add the code

    Put the widget on your page and copy the two backend calls from the snippets below

These are Cap integration examples, not production SDKs Your application must provide authentication, CSRF protection, form validation, canonical hashing of the final payload and business-operation idempotency Reuse the same verification key only for the same intent and identical request body WHMCS and WinSCP are Soon

Snippets

Snippets for every language

Replace captcha.example.com with your own Captchas.ir host. The credential and site ID are read from environment variables

HTML and fetch

contact.html
<form id="contact" method="post" action="/contact">
  <textarea name="message" required></textarea>
  <div id="captcha"></div>
  <input type="hidden" name="dc_receipt">
  <button>Send</button>
</form>

<script src="https://captcha.example.com/widget/cap.js"></script>
<script src="https://captcha.example.com/widget/vendor/cap.min.js"></script>
<script src="/js/contact.js"></script>
contact.js
const API = "https://captcha.example.com";
const SITE_KEY = "PUBLIC_SITE_KEY_FROM_PANEL";

(async () => {
  // Your backend creates the intent and keeps request_hash in the server session.
  const { intent_id } = await fetch("/captcha/intent", { method: "POST" }).then((r) => r.json());

  const res = await fetch(API + "/v1/sessions", {
    method: "POST",
    credentials: "omit",
    headers: {
      "content-type": "application/json",
      "x-digicaptcha-site-key": SITE_KEY,
      "idempotency-key": "sess-" + crypto.randomUUID(),
    },
    body: JSON.stringify({ intent_id, locale: "en" }),
  });
  const session = await res.json();
  if (!res.ok) throw new Error(session.error.code);

  const done = await DigiCaptcha.mountCap({
    apiBase: API,
    siteKey: SITE_KEY,
    sessionId: session.session_id,
    completionToken: session.completion_token,
    container: document.getElementById("captcha"),
  });
  document.querySelector("#contact [name=dc_receipt]").value = done.receipt;
})();

The Content-Security-Policy your host page needs is listed in the widget docs

PHP

captcha.php
<?php
const DC_API = 'https://captcha.example.com';

function dc_post(string $path, array $body, string $idemKey): array {
    $ch = curl_init(DC_API . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 5,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('DIGICAPTCHA_CREDENTIAL'),
            'Idempotency-Key: ' . $idemKey,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode($body),
    ]);
    $raw = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return [$status, $raw === false ? null : json_decode($raw, true)];
}

function dc_hash(array $fields): string {
    ksort($fields);
    return 'sha256:' . hash('sha256', json_encode($fields, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}

// POST /captcha/intent
function dc_create_intent(string $action): string {
    $hash = dc_hash(['action' => $action, 'nonce' => bin2hex(random_bytes(16))]);
    [$status, $res] = dc_post('/v1/intents', [
        'site_id'            => getenv('DIGICAPTCHA_SITE_ID'),
        'action'             => $action,
        'request_hash'       => $hash,
        'binding_version'    => 'jcs-v1',
        'expires_in_seconds' => 300,
    ], 'intent-' . bin2hex(random_bytes(16)));
    if ($status !== 201) {
        throw new RuntimeException($res['error']['code'] ?? 'DIGICAPTCHA_UNAVAILABLE');
    }
    $_SESSION['dc'] = ['intent_id' => $res['intent_id'], 'action' => $action, 'hash' => $hash];
    return $res['intent_id'];
}

// On form submit. Anything but 200 with success:true is a rejection.
function dc_verify(string $receipt): bool {
    $dc = $_SESSION['dc'] ?? null;
    if (!$dc || $receipt === '') {
        return false;
    }
    [$status, $res] = dc_post('/v1/siteverify', [
        'receipt'      => $receipt,
        'intent_id'    => $dc['intent_id'],
        'action'       => $dc['action'],
        'request_hash' => $dc['hash'],
    ], 'verify-' . $dc['intent_id']);
    $verified = $status === 200 && ($res['success'] ?? false) === true;
    if ($verified) unset($_SESSION['dc']);
    return $verified;
}

Python

captcha.py
import hashlib, json, os, secrets, urllib.error, urllib.request

API = "https://captcha.example.com"


def dc_post(path, body, idem_key):
    req = urllib.request.Request(
        API + path,
        data=json.dumps(body).encode(),
        method="POST",
        headers={
            "Authorization": "Bearer " + os.environ["DIGICAPTCHA_CREDENTIAL"],
            "Idempotency-Key": idem_key,
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=5) as res:
            return res.status, json.load(res)
    except urllib.error.HTTPError as err:
        return err.code, json.load(err)


def request_hash(fields):
    canonical = json.dumps(fields, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()


def create_intent(action):
    """Returns (intent_id, request_hash); keep both in the server session."""
    rh = request_hash({"action": action, "nonce": secrets.token_hex(16)})
    status, body = dc_post("/v1/intents", {
        "site_id": os.environ["DIGICAPTCHA_SITE_ID"],
        "action": action,
        "request_hash": rh,
        "binding_version": "jcs-v1",
        "expires_in_seconds": 300,
    }, "intent-" + secrets.token_hex(16))
    if status != 201:
        raise RuntimeError(body["error"]["code"])
    return body["intent_id"], rh


def verify(receipt, intent_id, action, rh):
    try:
        status, body = dc_post("/v1/siteverify", {
            "receipt": receipt,
            "intent_id": intent_id,
            "action": action,
            "request_hash": rh,
        }, "verify-" + intent_id)
    except (OSError, ValueError):
        return False  # network failure is a rejection
    return status == 200 and body.get("success") is True

Node.js

captcha.mjs · Node 18+
import { createHash, randomUUID } from "node:crypto";

const API = "https://captcha.example.com";

async function dcPost(path, body, idemKey) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.DIGICAPTCHA_CREDENTIAL}`,
      "idempotency-key": idemKey,
      "content-type": "application/json",
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(5000),
  });
  return { status: res.status, body: await res.json() };
}

// Flat objects only: keys sorted, compact JSON.
function requestHash(fields) {
  const sorted = Object.fromEntries(Object.entries(fields).sort(([a], [b]) => (a < b ? -1 : 1)));
  return "sha256:" + createHash("sha256").update(JSON.stringify(sorted)).digest("hex");
}

// Keep requestHash in the server session, never in the page.
export async function createIntent(action) {
  const rh = requestHash({ action, nonce: randomUUID() });
  const { status, body } = await dcPost("/v1/intents", {
    site_id: process.env.DIGICAPTCHA_SITE_ID,
    action,
    request_hash: rh,
    binding_version: "jcs-v1",
    expires_in_seconds: 300,
  }, `intent-${randomUUID()}`);
  if (status !== 201) throw new Error(body.error.code);
  return { intentId: body.intent_id, requestHash: rh };
}

export async function verify(receipt, intentId, action, rh) {
  try {
    const { status, body } = await dcPost("/v1/siteverify", {
      receipt,
      intent_id: intentId,
      action,
      request_hash: rh,
    }, `verify-${intentId}`);
    return status === 200 && body.success === true;
  } catch {
    return false; // timeout or network error is a rejection
  }
}

Go

captcha.go
package captcha

import (
	"bytes"
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

const api = "https://captcha.example.com"

var client = &http.Client{Timeout: 5 * time.Second}

func randomHex() string {
	var b [16]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

func post(ctx context.Context, path, idemKey string, body, out any) (int, error) {
	raw, err := json.Marshal(body)
	if err != nil {
		return 0, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, api+path, bytes.NewReader(raw))
	if err != nil {
		return 0, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("DIGICAPTCHA_CREDENTIAL"))
	req.Header.Set("Idempotency-Key", idemKey)
	req.Header.Set("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()
	return res.StatusCode, json.NewDecoder(res.Body).Decode(out)
}

// RequestHash relies on encoding/json sorting map keys.
func RequestHash(fields map[string]string) string {
	raw, _ := json.Marshal(fields)
	sum := sha256.Sum256(raw)
	return "sha256:" + hex.EncodeToString(sum[:])
}

// CreateIntent returns the intent ID and the hash to keep in the server session.
func CreateIntent(ctx context.Context, action string) (string, string, error) {
	hash := RequestHash(map[string]string{"action": action, "nonce": randomHex()})
	var out struct {
		IntentID string `json:"intent_id"`
	}
	status, err := post(ctx, "/v1/intents", "intent-"+randomHex(), map[string]any{
		"site_id":            os.Getenv("DIGICAPTCHA_SITE_ID"),
		"action":             action,
		"request_hash":       hash,
		"binding_version":    "jcs-v1",
		"expires_in_seconds": 300,
	}, &out)
	if err != nil || status != http.StatusCreated {
		return "", "", fmt.Errorf("digicaptcha intent: status %d: %v", status, err)
	}
	return out.IntentID, hash, nil
}

// Verify is true only for HTTP 200 with success:true.
func Verify(ctx context.Context, receipt, intentID, action, hash string) bool {
	var out struct {
		Success bool `json:"success"`
	}
	status, err := post(ctx, "/v1/siteverify", "verify-"+intentID, map[string]string{
		"receipt":      receipt,
		"intent_id":    intentID,
		"action":       action,
		"request_hash": hash,
	}, &out)
	return err == nil && status == http.StatusOK && out.Success
}

About request_hash These snippets build the hash from the action and a random nonce and keep it in the server session. If you want the receipt bound to the form contents themselves, create the intent when the form is submitted and compute the hash from those same fields

WordPress

A WordPress plugin is planned

There is no official WordPress plugin yet; it is planned but not released. Until then you can use the PHP snippet on this page inside your theme or a small custom plugin

Integration status

Ready

  • HTML and fetch Ready
  • PHP Ready
  • Python Ready
  • Node.js Ready
  • Go Ready

Planned

  • WordPress plugin Planned
FAQ

Integration questions

Do I need an SDK?

No. In the browser you load /widget/cap.js and the Cap widget, both served by your Captchas.ir server. On the server you make two HTTP requests with JSON bodies

Can Captchas.ir run on a different domain?

Yes. Add your site's origin to the allowed origins in the panel. Captchas.ir answers preflight requests and sends Access-Control-Allow-Origin only for those origins. You can also serve Captchas.ir under a path on the same origin through nginx

Where should the backend credential live?

Only on the server, for example in an environment variable. The panel shows it once. If it is lost or leaked, rotate it from the site page

What if siteverify does not answer?

Reject the user's request and ask them to try again. If you retry the same siteverify call, send the same Idempotency-Key so you get the stored result back and the receipt is not redeemed twice

How long is a receipt valid?

Two minutes. An intent lives 30 to 600 seconds and a session at most five minutes. If the user submits too late you get 410 SESSION_EXPIRED and need a fresh intent

When will the WordPress plugin ship?

It is planned, but no date has been announced. This page will be updated when it is released

Ready for your first site

Create a site, get your keys and try the whole flow in the playground