Documentation and getting started

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

From service setup to your first server-side verification Clear guidance for keys, widgets and security boundaries

On this page On this page

Introduction

Captchas.ir is a self-hosted CAPTCHA control plane One Go binary serves public and admin APIs, widgets and the Vue panel The public website is distinct from /panel/

Your backend creates an intent, the browser solves the selected engine challenge, and the backend consumes the receipt through /v1/siteverify These widget examples use Cap; other engines have their own renderer and contract

Web assets are self-hosted Engine traffic depends on that engine configuration PostgreSQL 18 and SQLite have local test evidence; a fresh PostgreSQL 16 and full deployment matrix remains incomplete

Status and scope

AreaStatusClaim boundary
Intent to receipt consumptionImplementedBackend credentials, request binding and atomic consumption
Engines and routingEnvironment dependentImplementation, configuration, health and real acceptance are distinct
Management and statsImplementedAdmin session, TOTP and CSRF controls; email needs SMTP
Commerce and plansIncompletePayments, quotas, invoicing and contractual SLA are not a complete product
Add-ons and SDKsSoonIntegration examples are not released official SDKs or plugins

The current data schema is 11 Inspect this environment through the panel and /v1/capabilities; implementation or successful health alone does not prove a real solve

Quick start

Building needs Go as pinned in go.mod. The panel is prebuilt and embedded in the binary

shell
# 1. build
go build -o digicaptcha ./cmd/digicaptcha

# 2. data key: exactly 32 bytes, standard base64; create once and keep it
install -d -m 700 /etc/digicaptcha /var/lib/digicaptcha
umask 077
if [ ! -f /etc/digicaptcha/data.key ]; then
  openssl rand -base64 32 > /etc/digicaptcha/data.key
fi

# 3. configure
export DIGICAPTCHA_DB=/var/lib/digicaptcha/digicaptcha.db
export DIGICAPTCHA_DATA_KEY="$(cat /etc/digicaptcha/data.key)"
export DIGICAPTCHA_ADMIN_USER=owner
read -r -s -p "Admin password: " DIGICAPTCHA_ADMIN_PASSWORD
export DIGICAPTCHA_ADMIN_PASSWORD

# 4. run (default 127.0.0.1:8088)
./digicaptcha -listen 127.0.0.1:8088

# 5. check
curl -s 127.0.0.1:8088/livez    # {"stage":"m1","status":"live"}
curl -s 127.0.0.1:8088/readyz   # {"stage":"m1-sqlite","status":"ready"}

Then open 127.0.0.1:8088/panel/ and sign in with that username and password /panel and /dashboard redirect to the panel too

Do not lose the data key Stored secrets and idempotency responses are encrypted with AES-GCM under this key. A new key means existing data cannot be read

Without DIGICAPTCHA_DB the process starts in foundation mode: /readyz and every public POST answer 503 with ADAPTERS_NOT_IMPLEMENTED, and no verification is ever produced

Environment variables

All settings come from the environment. The only command-line flag is -listen, default 127.0.0.1:8088

VariablePurpose
DIGICAPTCHA_DBA value starting with postgres:// or postgresql:// selects PostgreSQL; anything else is a SQLite file path. Empty means foundation mode
DIGICAPTCHA_DATA_KEYRequired with a database. Standard base64 of exactly 32 bytes, otherwise the process refuses to start
DIGICAPTCHA_ADMIN_USER
DIGICAPTCHA_ADMIN_PASSWORD
Creates the owner login at startup, or updates its password (argon2id). Once the password is changed in the panel, the panel password is kept on later starts
DIGICAPTCHA_PASSWORD_RESET1 in /etc/digicaptcha/env applies DIGICAPTCHA_ADMIN_PASSWORD again even after a panel change, clears the sign-in lock, and closes sessions. A shell export is not seen. Remove it after recovery. This flag does not clear a second factor
DIGICAPTCHA_BOOTSTRAP_TOKENOptional one-time token for POST /admin/v1/session/bootstrap when you have no password login yet
DIGICAPTCHA_COOKIE_SECUREtrue sets the Secure flag on the session and CSRF cookies. Required behind HTTPS
DIGICAPTCHA_MFA_RESET1 together with DIGICAPTCHA_ADMIN_USER clears the owner's second factor at startup. Remove it after recovery
DIGICAPTCHA_CAP_ENDPOINT
DIGICAPTCHA_CAP_ADMIN_KEY
URL of a local Cap Standalone (http or https, no path) and its admin key (at least 12 characters). The panel probes Cap's health and sites can connect without re-typing the key
DIGICAPTCHA_SMTP_HOST
DIGICAPTCHA_SMTP_PORT
DIGICAPTCHA_SMTP_USER
DIGICAPTCHA_SMTP_PASSWORD
DIGICAPTCHA_SMTP_FROM
Sends second-factor email codes. Enabled only when both HOST and FROM are set; port defaults to 587
DIGICAPTCHA_MCP_LISTENOptional IP:port whose IP must be loopback, for example 127.0.0.1:8090. Names such as localhost are refused. Empty means off
DIGICAPTCHA_MCP_TOKENOptional when a database is set, since tokens can be created in the panel; required without one. 16 to 128 characters, no whitespace. Clients send it as Authorization: Bearer

Behind nginx

Keep Captchas.ir on loopback and expose only nginx. Captchas.ir trusts X-Forwarded-For only when the request comes from loopback, so nginx must overwrite it with $remote_addr for the real visitor address to reach Cap

/etc/nginx/sites-available/digicaptcha.conf
server {
    listen 443 ssl;
    http2 on;
    server_name captcha.example.com;

    ssl_certificate     /etc/ssl/captcha.example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/captcha.example.com/privkey.pem;

    # Captchas.ir bodies are capped at 64 KiB anyway
    client_max_body_size 64k;

    location / {
        proxy_pass         http://127.0.0.1:8088;
        proxy_set_header   Host $host;
        proxy_set_header   X-Forwarded-For $remote_addr;
        proxy_read_timeout 20s;
    }
}

server {
    listen 80;
    server_name captcha.example.com;
    return 301 https://captcha.example.com$request_uri;
}
  • Set DIGICAPTCHA_COOKIE_SECURE=true
  • The browser's Origin header must reach Captchas.ir unchanged; nginx does this by default
  • Do not publish Cap Standalone. Only Captchas.ir should be able to reach it

Sites and keys in the panel

  1. Sign in and add a second factor Sign in at /panel/ and enable a TOTP app, or email once SMTP is configured, on the Security page
  2. New site Enter a name, an environment such as production, and the allowed origins. Each origin is complete, for example https://shop.example.com
  3. Site key Create a public site key on the site page. It is used in the browser as X-DigiCaptcha-Site-Key and is not a secret
  4. Backend credential Create a credential with the intents:create and receipts:verify scopes. The full value, shaped dck_<prefix>_<secret>, is shown exactly once; afterwards only the eight-character prefix is visible
  5. Connect Cap Attach the site to Cap. With DIGICAPTCHA_CAP_ENDPOINT set you need not enter a URL or key. Captchas.ir creates a Cap site key with instrumentation on, default difficulty 1000000 and obfuscation level 3
  6. Try it From the site page, open /playground/ to run the whole flow in your browser and see a real siteverify

Both backend credentials and the Cap instance secret can be rotated. A rotated credential keeps its scopes, and the new value is again shown only once

One site per domain

Create a separate site for every domain that needs a captcha, for example a Pulse site with origin https://pulse.digitalvps.ir and a BGP Map site with origin https://bgpmap.digitalvps.ir. Each site has its own public key, server key and Cap instance, and a key of one site does not work on the origin of another

  1. At /panel/sites/new enter the name, environment and the domain origin, with no path and no trailing slash
  2. On the site page the “Set up this site” box shows four steps: origin, public key, server key and Cap instance
  3. Put the public key in the page HTML and the server key only in that domain's backend configuration
  4. The “Browser test” button runs from the panel itself, so the panel address never needs to be added to a site's origins

Managing keys and sites

  • Every public key and server key can carry a label, such as “signup form”
  • Each key can be revoked on its own, without issuing a replacement
  • Server key scopes are editable and a narrowed scope applies from the next request, the secret itself changes only by rotation
  • A new secret is shown masked, the eye button reveals it and its box closes after two minutes
  • Deleting a site requires typing its exact name, every key and instance on it stops at once and the audit history stays

Verification flow

sequence
Backend            Browser                    Captchas.ir                 Cap (loopback)
   |                  |                            |                            |
   |-- POST /v1/intents (Bearer) ----------------->|                            |
   |<-------------------------------- intent_id ---|                            |
   |-- intent_id ---->|                            |                            |
   |                  |-- POST /v1/sessions ------>|  (X-DigiCaptcha-Site-Key)  |
   |                  |<- session_id, completion --|                            |
   |                  |-- .../cap/challenge ------>|-- challenge --------------->|
   |                  |<---------------------------|<---------------------------|
   |                  |   (solve proof-of-work)    |                            |
   |                  |-- .../cap/redeem --------->|-- redeem ------------------>|
   |                  |-- .../complete ----------->|-- siteverify (secret) ----->|
   |                  |<---------------- receipt --|                            |
   |<-- receipt ------|                            |                            |
   |-- POST /v1/siteverify (Bearer) -------------->|  consume once, atomically  |
   |<------------------------------ success:true --|                            |
  • An intent lives 30 to 600 seconds (default 300)
  • A session lives at most 5 minutes and never longer than the intent's remaining time
  • A receipt is valid for 2 minutes and can be consumed once
  • Challenge and redeem responses are not successes. Only complete issues a receipt, and only siteverify confirms it

Browser widget

Captchas.ir serves two browser files: /widget/cap.js, which drives the flow, and the official Cap widget at /widget/vendor/cap.min.js with its WASM files. Order matters: cap.js must load first so Cap fetches its WASM from this origin, not from a CDN

signup.html
<form id="signup" method="post" action="/signup">
  <input name="email" type="email" required>
  <div id="captcha"></div>
  <input type="hidden" name="dc_intent">
  <input type="hidden" name="dc_receipt">
  <button>Sign up</button>
</form>

<script src="https://captcha.example.com/widget/cap.js" nonce="RANDOM"></script>
<script src="https://captcha.example.com/widget/vendor/cap.min.js" nonce="RANDOM"></script>
<script src="/js/signup.js" nonce="RANDOM"></script>
signup.js
const API = "https://captcha.example.com";
const SITE_KEY = "PUBLIC_SITE_KEY_FROM_PANEL";
const form = document.getElementById("signup");

async function startCaptcha() {
  // 1. Your backend creates the intent; it holds the credential.
  const intent = await fetch("/captcha/intent", { method: "POST" }).then((r) => r.json());

  // 2. Open a Captchas.ir session with the public site key.
  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: intent.intent_id, locale: "en" }),
  });
  const session = await res.json();
  if (!res.ok) throw new Error(session.error.code);

  // 3. Render Cap; resolves with the Captchas.ir receipt.
  const done = await DigiCaptcha.mountCap({
    apiBase: API,
    siteKey: SITE_KEY,
    sessionId: session.session_id,
    completionToken: session.completion_token,
    container: document.getElementById("captcha"),
  });
  form.elements.dc_intent.value = done.intent_id;
  form.elements.dc_receipt.value = done.receipt;
}

startCaptcha().catch((err) => console.error("captcha", err));

DigiCaptcha.mountCap(options)

Renders the official Cap widget inside container, routes its challenge and redeem calls through the Captchas.ir session, and calls /complete once solved. The promise resolves with the complete response, {receipt, receipt_expires_at, intent_id, session_id}

siteKey
Public site key. Required
sessionId
session_id from POST /v1/sessions. Required
completionToken
completion_token from the same response. Required
container
DOM element that receives the widget. Required
apiBase
Captchas.ir origin. Defaults to the origin cap.js was loaded from
lang
"fa" switches the widget labels to Persian
labels
Override labels using Cap's keys, such as initial-state
autoSolve
Ignored. Solving starts only after the person clicks the widget

DigiCaptcha.runCap(options)

For a custom solver or tests, without the official widget. Options: apiBase, siteKey, sessionId, completionToken, idempotency: {challenge, redeem, complete} and solve(challenge), which must return {token, solutions}. It resolves with the complete response. Define globalThis.DigiCaptchaTransport to replace fetch

Host page CSP

The Cap widget uses WebAssembly and a Web Worker for proof-of-work and injects its own styles with a nonce cap.js hands its own script tag's nonce to Cap. A policy like this one, taken from Captchas.ir's own playground page, is enough:

Content-Security-Policy
script-src 'self' https://captcha.example.com 'nonce-RANDOM' 'wasm-unsafe-eval' 'unsafe-eval';
style-src 'self' 'nonce-RANDOM';
worker-src 'self' blob:;
connect-src 'self' https://captcha.example.com;
frame-src 'self'

If Captchas.ir is served on the site's own origin (for example via an nginx location), the captcha.example.com entries are unnecessary. Browsers without DecompressionStream cannot unpack Cap's instrumentation, so the challenge fails on purpose; no library is fetched from a CDN

Prosopo OSS

Create a Prosopo site in the panel, select pow, puzzle, iconOrder or image and register the customer site’s exact origin

Your backend creates the intent and the browser uses the normal session response below Keep backend credentials and native registration secrets on the server

JavaScript
<script src="https://captchas.ir/widget/engines.js"></script>
const controller = new AbortController()
const result = await DigiCaptcha.mountProsopo({
  apiBase: 'https://captchas.ir',
  siteKey: YOUR_PUBLIC_SITE_KEY,
  prosopoSiteKey: session.challenge.public_config.prosopo_site_key,
  sessionId: session.session_id,
  completionToken: session.completion_token,
  container: document.getElementById('captcha'),
  lang: 'en',
  signal: controller.signal
})
// Send result.receipt to your backend for /v1/siteverify
// controller.abort() cancels this widget only

Perform the protected action only after backend verification and one-use receipt consumption A network error is not success and an issued session must not switch engines

The three visual modes offer an English spoken-digit audio alternative Persian audio and Prosopo’s managed behavioral detector are not included Custom image class labels may use the English fallback

Browser modules and WASM load from this service The style adapter uses the trusted page script nonce For local audio restrict media-src to self and data

Test your configured site in the panel playground

Backend verification

Your backend makes two calls, both with the backend credential and an Idempotency-Key. The credential must never reach a browser

1. Create an intent

shell
curl -sS https://captcha.example.com/v1/intents \
  -H "Authorization: Bearer $DIGICAPTCHA_CREDENTIAL" \
  -H "Idempotency-Key: intent-$(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "SITE_ID",
    "action": "signup",
    "request_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "binding_version": "jcs-v1",
    "expires_in_seconds": 300
  }'

# 201 Created
{"intent_id":"4b7c0d1e2f3a4b5c6d7e8f9a0b1c2d3e","action":"signup","expires_at":"2026-10-25T08:05:00Z"}

Your backend computes request_hash; Captchas.ir only compares it. The format is sha256: followed by 64 lowercase hex characters binding_version is currently only jcs-v1, which refers to the RFC 8785 canonical form; what matters is that you hash exactly the same bytes when creating the intent and when calling siteverify

2. Consume the receipt

shell
curl -sS https://captcha.example.com/v1/siteverify \
  -H "Authorization: Bearer $DIGICAPTCHA_CREDENTIAL" \
  -H "Idempotency-Key: verify-4b7c0d1e2f3a4b5c6d7e8f9a0b1c2d3e" \
  -H "Content-Type: application/json" \
  -d '{
    "receipt": "RECEIPT_FROM_FORM",
    "intent_id": "4b7c0d1e2f3a4b5c6d7e8f9a0b1c2d3e",
    "action": "signup",
    "request_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
  }'

# 200 OK
{"success":true,"verification_id":"…","intent_id":"4b7c…","site_id":"…","action":"signup",
 "request_hash":"sha256:9f86…","verified_at":"2026-10-25T08:01:12Z"}

Golden rule Proceed only on HTTP 200 with success equal to true. Anything else, including timeouts and network errors, is a rejection

Snippets for PHP, Python, Node and Go are on the integrations page

HTTP API reference

The dedicated reference covers request creation, sessions and receipt consumption

Errors

Every error has the same shape:

json
{
  "error": {
    "code": "ALREADY_CONSUMED",
    "message": "This receipt was already consumed.",
    "request_id": "5f0c2a9e8b7d4c3e1a2b3c4d5e6f7a8b",
    "retryable": false
  }
}
HTTPcodeMeaning
400INVALID_BODY · INVALID_REQUESTBad JSON, unknown field or failed validation, including a missing Idempotency-Key
401UNAUTHENTICATEDCredential, key or session missing or invalid
403FORBIDDENMissing scope, disallowed origin or wrong CSRF token
403BINDING_MISMATCHReceipt or intent belongs to another site, action or installation
404NOT_FOUNDNot found in this tenant
409ALREADY_CONSUMEDThe receipt was already consumed
409IDEMPOTENCY_CONFLICTSame key with a different body
409OPERATION_IN_PROGRESSThe same operation is still running; retryable: true
409CONFLICTThe resource is not in a state that accepts this operation
410SESSION_EXPIREDIntent, session or receipt has expired
413BODY_TOO_LARGEBody over 64 KiB
400CAPTCHA_REQUIREDThe panel sign-in captcha is on and its receipt is missing or was not accepted
422INVALID_PROOFCap rejected the token, or it belongs to another Cap site
502UPSTREAM_REJECTEDCap rejected an admin request
503UPSTREAM_UNAVAILABLECap unreachable for challenge or redeem; retryable: true
503UPSTREAM_RESULT_UNKNOWNVerification outcome unknown; no receipt issued, do not retry with the same proof
503NO_ELIGIBLE_PROVIDERNo eligible active engine is available for the site
503STORAGE_UNAVAILABLEDatabase unavailable; retryable: true
429RATE_LIMITEDToo many sign-in or browser session calls from one address, or the site's per-IP session cap is full; wait for Retry-After seconds
503MAIL_NOT_CONFIGUREDSMTP is not configured
503ADAPTERS_NOT_IMPLEMENTEDFoundation mode, no database
500INTERNAL_ERRORInternal error; the operation failed closed

Idempotency

  • Generate one random key per logical operation and send the same key when retrying that operation
  • The same key with the same body returns the stored response. That is not a second consumption and creates no new success
  • The same key with a different body gets 409 IDEMPOTENCY_CONFLICT
  • If the earlier operation is still running you get 409 OPERATION_IN_PROGRESS
  • After UPSTREAM_RESULT_UNKNOWN do not retry blindly. Start a new intent and session

Monitor and MCP

The panel's monitor page is fed by GET /admin/v1/monitor:

FieldMeaning
engine · schema · ping_msDatabase engine, schema version and ping latency
poolopen, in_use and idle connections
countsNumber of sites, users and receipts
mail_configuredWhether SMTP is configured
capstate is running, unreachable or contract_tested_not_live_cap, plus latency_ms, version and commit
localDatabase host, captcha_upstreams: 0, cap_loopback, panel_assets: "origin" and the mcp state

The MCP listener is optional and opens, separate from the main port, on the loopback address you give in DIGICAPTCHA_MCP_LISTEN. It exposes two read-only tools over Streamable HTTP, digicaptcha_status and digicaptcha_traffic (today per site, countries over seven days and the last 24 hours). Every request needs Authorization: Bearer with a token created in the panel under Settings, or DIGICAPTCHA_MCP_TOKEN from the service settings. It runs no SQL, no shell and no outbound request

Security notes

  • Keep backend credentials (dck_…) on the server only. If one leaks, rotate it from the panel
  • Give each site only its exact origins. Browser calls without an Origin, or with another one, are refused
  • Keep Cap Standalone and the MCP port on loopback
  • Behind TLS, do not forget DIGICAPTCHA_COOKIE_SECURE=true
  • Enable a second factor for the owner, and remove DIGICAPTCHA_MFA_RESET after use
  • Store the data key apart from database backups
  • In your backend, never treat a timeout or network error from siteverify as a pass
  • Receipts are stored only as hashes, and the widget sends requests with credentials: "omit"

Troubleshooting

SymptomLikely cause
The process exits with DIGICAPTCHA_DATA_KEY must be standard base64…The key is not 32 bytes or not standard base64. Use openssl rand -base64 32
/readyz returns 503Foundation mode without DIGICAPTCHA_DB, or the database is unreachable
After signing in you land on the login page againDIGICAPTCHA_COOKIE_SECURE=true on plain HTTP; the browser drops Secure cookies. Turn it on only behind HTTPS
Every admin API change gets 403 FORBIDDENNo X-CSRF-Token header matching this session's digicaptcha_csrf cookie
POST /v1/sessions returns 403The page's Origin is not in the site's allowed origins, or the site key belongs to another site
NO_ELIGIBLE_PROVIDERThe site is not connected to a Cap instance yet
Monitor shows Cap as unreachableCap does not answer its health check at DIGICAPTCHA_CAP_ENDPOINT
Widget does not render or WASM failsScript order or the page's CSP cap.js must come before cap.min.js
siteverify returns BINDING_MISMATCHaction, intent or request_hash differ from intent creation, or the credential belongs to another site
Email codes never arriveDIGICAPTCHA_SMTP_HOST or DIGICAPTCHA_SMTP_FROM is not set
Panel password forgotten and DIGICAPTCHA_PASSWORD_RESET=1 has no effectPut the line in /etc/digicaptcha/env and run systemctl restart digicaptcha. The journal must say admin password reset from environment. The password is DIGICAPTCHA_ADMIN_PASSWORD
Second factor lostAlso put DIGICAPTCHA_MFA_RESET=1 in /etc/digicaptcha/env for one restart, then remove it

Licenses

  • The panel UI is derived from GateCHA (MIT)
  • The Cap widget and WASM files are unmodified copies from Cap Standalone (Apache-2.0)
  • The Vazirmatn font is under the SIL Open Font License 1.1
  • Flags are from flag-icons 7.6.0 under the MIT License
  • The world map is generated from world-atlas 2.0.2 (Natural Earth 1:110m, public domain; package under the ISC License)
  • Country lookup uses IP2Location LITE data compiled into the binary, so no request leaves the host. This site or product includes IP2Location LITE data available from https://lite.ip2location.com

Widget editions and verification methods

V1 uses only Sum V2 uses only visible PoW V3 uses only Marks V4 uses only invisible PoW V5 uses only advanced Marks Each site saves its policy in the panel and existing sessions keep their issued policy

V3 has no behavioral score and V4 runs only invisible PoW on the protected action with a branded badge Browser work does not prove humanity

Digital Captcha Lab