Three steps. Five minutes. Better customer experience than another hostile CAPTCHA.
The technical integration is deliberately simple, but the product point is not just speed. You are replacing a verification moment people often resent with something shorter, lighter, and less punitive.
Before you start
Sign up, enter the domain you want to protect, and we’ll provision your first project automatically. Then copy the public site key and secret key from the get-started screen.
Why teams switch
The integration is fast because the main decision should be product fit, not tooling pain. Most teams switching to Playtcha are trying to replace a verification moment users dislike with something lighter, shorter, and less hostile.
Read playtcha-token from the form submission, POST it with your secret key to /v1/verify, and follow the fail-open contract: a 5xx or network error treats the user as verified (so a Playtcha outage does not break your form), a 4xx (token_invalid,token_expired, already_redeemed, bad_secret) rejects deterministically. Opt into strict mode with PLAYTCHA_FAIL_CLOSED=1.
Node
javascript
// Node 18+ — global fetch. Mirror of the canonical pattern in the dashboard.
// Fail-open contract:
// 5xx / network error -> verifier unavailable -> treat user as human (log)
// 4xx (token_invalid, token_expired, already_redeemed, bad_secret) -> reject
// Set PLAYTCHA_FAIL_CLOSED=1 to reject on the unavailable path instead.
try {
const res = await fetch("https://playtcha.com/v1/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.PLAYTCHA_SECRET,
token: req.body["playtcha-token"],
}),
signal: AbortSignal.timeout(5000),
});
const data = await res.json().catch(() => null);
if (res.status >= 500 || !data) {
console.warn("playtcha.verify.unavailable", { status: res.status });
if (process.env.PLAYTCHA_FAIL_CLOSED === "1") {
return res.status(503).send("verifier unavailable");
}
// fall through — treat as verified
} else if (!data.success) {
return res.status(400).send("verification failed");
}
// data.degraded === true means usage is over the cap. Log it, don't block.
} catch (err) {
console.warn("playtcha.verify.unreachable", { err: String(err) });
if (process.env.PLAYTCHA_FAIL_CLOSED === "1") {
return res.status(503).send("verifier unavailable");
}
}
Python
python
# Flask / Django / FastAPI — same shape.
# 5xx / network -> fail open (logged). 4xx -> reject. Opt-in to fail-closed.
import logging, os, requests
log = logging.getLogger(__name__)
fail_closed = os.environ.get("PLAYTCHA_FAIL_CLOSED") == "1"
try:
r = requests.post(
"https://playtcha.com/v1/verify",
json={"secret": os.environ["PLAYTCHA_SECRET"], "token": request.form["playtcha-token"]},
timeout=5,
)
except requests.RequestException as e:
log.warning("playtcha.verify.unreachable err=%s", e)
if fail_closed:
abort(503)
else:
if r.status_code >= 500:
log.warning("playtcha.verify.unavailable status=%s", r.status_code)
if fail_closed:
abort(503)
else:
data = r.json()
if not data.get("success"):
abort(400) # 4xx token_invalid / token_expired / already_redeemed / bad_secret
# data.get("degraded") -> log; do not block
Go
go
// 5xx / network -> fail open (logged). 4xx -> reject deterministically.
body, _ := json.Marshal(map[string]string{
"secret": os.Getenv("PLAYTCHA_SECRET"),
"token": r.FormValue("playtcha-token"),
})
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://playtcha.com/v1/verify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
failClosed := os.Getenv("PLAYTCHA_FAIL_CLOSED") == "1"
if err != nil || res.StatusCode >= 500 {
log.Printf("playtcha.verify.unavailable err=%v status=%d", err, statusCode(res))
if failClosed { http.Error(w, "verifier unavailable", 503); return }
// fall through — treat as verified
} else {
defer res.Body.Close()
var out struct{ Success, Degraded bool `json:""` }
_ = json.NewDecoder(res.Body).Decode(&out)
if !out.Success { http.Error(w, "verification failed", 400); return }
// out.Degraded -> log; do not block
}
Ruby
ruby
# 5xx / network -> fail open (logged). 4xx -> reject deterministically.
require "net/http"
require "json"
fail_closed = ENV["PLAYTCHA_FAIL_CLOSED"] == "1"
begin
uri = URI("https://playtcha.com/v1/verify")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 5
http.open_timeout = 5
req = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
req.body = { secret: ENV["PLAYTCHA_SECRET"], token: params["playtcha-token"] }.to_json
res = http.request(req)
rescue StandardError => e
logger.warn("playtcha.verify.unreachable err=#{e}")
halt 503 if fail_closed
else
if res.code.to_i >= 500
logger.warn("playtcha.verify.unavailable status=#{res.code}")
halt 503 if fail_closed
else
data = JSON.parse(res.body)
halt 400 unless data["success"]
# data["degraded"] -> log; do not block
end
end
If your frontend is a SPA that POSTs JSON to its own API, you don’t need a <form>. Use the JS API to capture the result token in state, send it as part of your JSON body, and disable the hidden-input injection so it doesn’t mutate any ancestor form.
signup.tsx
// Anywhere on the page, plus this small bit of JS:
const el = document.querySelector(".playtcha");
window.playtcha.render(el, {
responseFieldName: null, // SPA mode: skip the hidden input
onSuccess: (token) => {
// Send the result token in your JSON request body.
void fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, playtchaToken: token }),
});
},
onExpired: () => {
// The result token TTL elapsed before submit. Ask the user to play again.
},
onError: (err) => {
console.warn("playtcha widget error", err);
},
});
Server side: read req.body.playtchaToken instead ofreq.body["playtcha-token"]. The rest of the verify pattern is identical.
JS API: onSuccess / onExpired / onError
Every render accepts three callbacks. The widget invokes the matching one once per state change.
onSuccess(token) — fired with the result token after a successful play.
onExpired() — fired if the captured result token reaches its 5-minute TTL before submit.
You can also attribute the same callbacks via HTML attributes (data-callback, data-expired-callback, data-error-callback) referencing global functions, if you prefer the markup-only path.
For Cursor / Claude Code / Copilot
Want the AI-assisted version?
Paste our canonical prompt into your assistant after you’ve skimmed the manual path. It scaffolds the integration, but the quickstart above stays the source of truth.
What happens if Playtcha is unreachable?
By default, Playtcha fails open: when our verifier is unavailable (5xx response, network error, or timeout), the documented server pattern treats the user as human and your form keeps working. 4xx responses (token_invalid, token_expired, already_redeemed, bad_secret, unknown_project) are still rejected deterministically. High-stakes flows can opt into fail-closed by setting PLAYTCHA_FAIL_CLOSED=1. Read the full pattern with copy-paste-runnable code in failure handling.
What if a verify response has degraded: true?
success: true with degraded: true means the project crossed its monthly tier cap and Playtcha returned success anyway. Log it (the degraded_reason field tells you which band: quota_exceeded_soft, trial_grace, etc.), but treat the user as verified. This is our graceful-degradation contract — never break a customer’s form because of our billing state.
Failure codes you will actually see
400 invalid_json / invalid_body — your request body is malformed. Bug on your side.
401 token_invalid — signature or claims rejected. Likely tamper or wrong environment (live vs test).
401 bad_secret — the secret does not match the project. Config error.
404 unknown_project — project was deleted or never existed. Config error.
409 already_redeemed — single-use by design. This is a security signal: replay attack, stuck retry loop, or duplicate POST. Log with request_id at warn level.
410 token_expired — result token TTL (5 minutes) elapsed. Ask the user to retry.
429 rate_limited_ip — per-IP throttle. Response includes a Retry-After header. Honor it; do not retry tighter than 1s.
500 db_error — Playtcha-side. Fail open.
Atomic signup: verify-dry then verify
For signup flows where the captcha must succeed alongside an account-creation side effect that can fail for unrelated reasons (duplicate email, weak password, etc.), use POST /v1/verify-dry first. Same input shape as /v1/verify, but it does not redeem the token or bump usage. Then call /v1/verify only after your real side effect succeeds. This is how you avoid burning a user’s captcha on a validation failure. See the Supabase Auth guide for a full example.
Mobile and accessibility
The widget is a minigame, not a 78×78px checkbox. Budget at least 240px of vertical space in your form layout so the play surface is usable on small phones. The games respect prefers-reduced-motion and route through pointer + touch + keyboard, so users on a tracker pad, a phone, or a keyboard-only flow all get a path.
What’s next
Add allowed domains to your project so the public key only works where you say. See the domain whitelist reference.
If you are protecting a specific flow, read the dedicated guides for signup, login, or contact forms.
Watch verifications-per-hour in the dashboard.
If you run on Next.js + Vercel or React + Vite, follow the Next.js guide or the React guide for the right client/server split.
If Supabase Auth owns signup, use the Supabase Auth guide so verification runs before account creation.
Need backend examples? Use the verify references for Node, Python, or PHP, Go, or Ruby.
Listen for onSuccess(token) / onExpired() / onError(err) via the JS API for SPAs.
Before you go live, read failure handling so your server implements the fail-open contract our policy promises.
Reference docs are being split out into dedicated pages from this quickstart. Start here, then move into the docs hub for migration and framework-specific guides.