A CAPTCHA vendor that hard-fails customer signup forms during its own outage is a CAPTCHA vendor you stop integrating with. This article is the contract we offer instead: by default, Playtcha fails open during a Playtcha outage, your form keeps working, and the bot-tolerance trade-off is something you opt into knowingly.
Why Playtcha fails open
A bot getting through during a Playtcha outage is a real cost. Your signup or contact form being broken during a Playtcha outage is a larger cost — every legitimate visitor in that window leaves, and a third-party verifier outage becomes your downtime in your customers’ eyes. The asymmetry only gets worse the bigger you scale: a one-hour outage at 1% bot baseline accepts a handful of extra junk submissions; a one-hour outage at fail-closed loses 100% of real conversions.
We took the position that the right default is to let your form keep working. We back the position with three things:
- A documented server-side pattern (this article) so the customer servers we don’t control behave the way the policy promises.
- A widget-side
onUnavailablecallback (coming in the next widget release) so the customer’s choice is explicit, auditable, and visible in their own code. - A Sev-1 incident email to project owners (alerting pipeline in progress) so an outage is observable from outside, not just from our metrics dashboard.
None of this changes the 4xx case. If your token is invalid, replayed, or your secret is wrong, the verifier rejects deterministically. Fail-open only kicks in when we can’t answer at all.
What hCaptcha, Friendly Captcha, and reCAPTCHA do
We’re not inventing this stance. Every serious CAPTCHA vendor has had to answer the same question, and they all land in roughly the same place:
- hCaptcha documents a fail-open recommendation for outage handling: treat a non-response from the verify API as a soft pass, log it, and rely on the rest of your abuse controls.
- Friendly Captcha recommends the same shape — a short timeout, a soft pass on network failure, and a deterministic reject only when the verify endpoint returns a definitive negative.
- reCAPTCHA v3 fails open by default, but it accomplishes that through background telemetry: when their backend is degraded, the score field on the client side continues to return optimistic scores derived from cached behavioral signals. Playtcha cannot do this — our privacy promise is that we don’t collect background behavioral signals to begin with. So we get to the same place a different way: by documenting the customer-server pattern that makes fail-open a property of your code, not of opaque vendor telemetry.
If you came here from a comparison article — see reCAPTCHA vs Playtcha or hCaptcha vs Playtcha — this is the page that fills in the “what happens during an outage” row.
The 5xx-vs-4xx contract
The customer-server pattern below treats two classes of response differently. The split is load-bearing — if you collapse it, you either fail closed on every blip or you accept any garbage token as valid.
- 5xx response or network error from
/v1/verify— treat as “verifier unavailable.” Fail open. Log the event with therequest_idif the response had one. This is the outage case. - 4xx response with an explicit error code (
token_invalid,already_redeemed,bad_secret) — deterministic rejection. Do not fail open. This is either a bug in your integration or an active replay/forgery attempt; both deserve to surface as rejection. - Timeout — same class as a 5xx. Fail open. Use a tight timeout (we recommend 5 seconds) so a slow verifier doesn’t stall the form indefinitely.
The opt-out: customers running high-stakes flows (a wire transfer, an admin action, anything where a false-positive bot is worse than a brief outage to legitimate users) read the env var PLAYTCHA_FAIL_CLOSED=1 and reject when the verifier is unavailable. The default stays fail-open; the explicit opt-in keeps the choice visible in your own code review.
The pattern in Node
Node 20+ — uses global fetch and AbortSignal.timeout. Drop this in your form-submit handler, after you’ve read the token off the request body.
// Fail-open contract — see /learn/failure-handling
// 5xx / network error -> verifier unavailable -> treat user as human
// 4xx (token_invalid,
// already_redeemed,
// bad_secret) -> deterministic rejection (bug or attack)
// Set PLAYTCHA_FAIL_CLOSED=1 to reject on the unavailable path instead.
const failClosed = process.env.PLAYTCHA_FAIL_CLOSED === "1";
try {
const r = 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 r.json().catch(() => null);
if (r.status >= 500 || !data) {
// Verifier unavailable. Fail open unless opted out.
console.warn("playtcha.verify.unavailable", {
status: r.status,
request_id: data?.error?.request_id,
});
if (failClosed) {
return res.status(503).json({ error: "verifier_unavailable" });
}
// Otherwise: fall through, treat as verified.
} else if (!data.success) {
// 4xx — deterministic rejection. Don't fail open.
return res.status(400).json({ error: "captcha_failed" });
}
// success: true OR (5xx + fail-open) — continue with the request.
} catch (err) {
// Network error, timeout, DNS failure — same class as 5xx.
console.warn("playtcha.verify.unreachable", { err: String(err) });
if (failClosed) {
return res.status(503).json({ error: "verifier_unavailable" });
}
// Otherwise: fall through, treat as verified.
}The pattern in Python
Python 3.10+ with requests. Same shape as Node — a 5xx or transport error is fail-open by default; a 4xx is a hard rejection.
# Fail-open contract — see /learn/failure-handling
# 5xx / network error -> verifier unavailable -> treat user as human
# 4xx (token_invalid,
# already_redeemed,
# bad_secret) -> deterministic rejection (bug or attack)
# Set PLAYTCHA_FAIL_CLOSED=1 to reject on the unavailable path instead.
import logging, os, requests
log = logging.getLogger(__name__)
def verify_captcha(token: str) -> bool:
"""Return True if the user should be treated as human."""
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": token},
timeout=5,
)
except requests.RequestException as e:
log.warning("playtcha.verify.unreachable err=%s", e)
return not fail_closed # fail open by default
if r.status_code >= 500:
try:
request_id = r.json().get("error", {}).get("request_id")
except ValueError:
request_id = None
log.warning(
"playtcha.verify.unavailable status=%s request_id=%s",
r.status_code, request_id,
)
return not fail_closed # fail open by default
# 2xx or 4xx — trust the response body.
data = r.json()
return bool(data.get("success"))The pattern in Go
Go 1.21+ with the standard library. Same contract — a 5xx or transport-level failure falls through to fail-open; a 4xx is a deterministic rejection.
// Fail-open contract — see /learn/failure-handling
// 5xx / network error -> verifier unavailable -> treat user as human
// 4xx (token_invalid,
// already_redeemed,
// bad_secret) -> deterministic rejection (bug or attack)
// Set PLAYTCHA_FAIL_CLOSED=1 to reject on the unavailable path instead.
package captcha
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"time"
)
type verifyResp struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
RequestID string `json:"request_id"`
} `json:"error"`
}
// Verify returns true if the user should be treated as human.
// It implements Playtcha's fail-open contract: a 5xx or network error
// is treated as "verifier unavailable" and falls open unless
// PLAYTCHA_FAIL_CLOSED=1.
func Verify(ctx context.Context, token string) bool {
failClosed := os.Getenv("PLAYTCHA_FAIL_CLOSED") == "1"
body, _ := json.Marshal(map[string]string{
"secret": os.Getenv("PLAYTCHA_SECRET"),
"token": token,
})
ctx, cancel := context.WithTimeout(ctx, 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)
if err != nil {
slog.Warn("playtcha.verify.unreachable", "err", err)
return !failClosed // fail open by default
}
defer res.Body.Close()
if res.StatusCode >= 500 {
var data verifyResp
_ = json.NewDecoder(res.Body).Decode(&data)
slog.Warn("playtcha.verify.unavailable",
"status", res.StatusCode,
"request_id", data.Error.RequestID,
)
return !failClosed // fail open by default
}
// 2xx or 4xx — trust the response body.
var data verifyResp
if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
slog.Warn("playtcha.verify.decode_failed", "err", err)
return !failClosed
}
return data.Success
}When to opt into fail-closed
The fail-open default is right for almost everyone — signup, login, contact, comment, password reset, free-trial signup. The opt-out exists for the narrow set of flows where the false-positive-bot cost exceeds the lost-conversion cost during an outage:
- Financial transfers. Moving money to a new beneficiary. Wire transfer initiation. Crypto withdrawal.
- Admin actions. Account deletion, role escalation, granting access to a billable resource.
- One-shot high-value redemption. A coupon code worth more than your typical transaction; a referral payout claim.
For those flows, set PLAYTCHA_FAIL_CLOSED=1 in the environment of the service that handles them. We recommend scoping that env var per-service rather than globally — your login form and your wire-transfer service usually have different risk profiles, and a fleet-wide fail-closed switch means your login also breaks during a Playtcha outage. For everything else, leave the default.
Where to watch outages
We don’t run a public status page. Status pages that ship on the same infrastructure as the product are theater — the outage takes the status page down with it — and a separately hosted page that contradicts the live product surface during an incident is worse than nothing. Instead, the trust page publishes 7-day rolling verification volume and bots-blocked-by-category pulled live from the same backend you depend on. If those numbers freeze or drop to zero, that’s the signal.
When a Sev-1 incident is declared, project owners receive an email at incident start and again at resolution. That alerting pipeline is in the works as of this article — once it lands, the trust page will list the criteria that trigger an email so you’re never surprised. Until then, support@playtcha.com is the fastest path to a human if your form is acting up and you’re not sure whether we’re the cause.
FAQ
Doesn’t fail-open let bots through during your outages?
Yes — by design, during the window when our verify endpoint can’t answer. The trade-off is deliberate: a one-hour outage at fail-closed loses 100% of real signups in that window; the same outage at fail-open accepts whatever your steady-state bot ratio is on top of legitimate traffic. For almost every Playtcha customer, the second number is much smaller than the first. The fail-closed opt-out exists for the cases where it isn’t.
What counts as a 5xx vs a 4xx from /v1/verify?
5xx: 500, 502, 503, 504. Anything in the 5xx range, plus connection errors, DNS failures, and timeouts, is the “unavailable” class. 4xx: 400 with token_invalid, token_expired, or already_redeemed; 401 with bad_secret; 403 with domain_mismatch. These are deterministic rejections that mean either the integration is wrong or the token is forged. Reject them, don’t fail open.
Will the widget itself fail open if the verifier is down?
That’s the upcoming onUnavailable callback on the widget side — it lets the customer-side JavaScript see a verifier outage and make the same fail-open decision in the browser before the form is even submitted. Until that ships, the customer-server pattern in this article is the contract. You’ll still want it after the callback ships, because the server is where the security gate actually lives.
Should I log every verifier unavailable event?
Yes — log it at warn level with the request_id if you got one. That gives you the audit trail you need to (a) reconcile your bot-tolerance assumptions after an incident and (b) tell your own stakeholders “these signups passed because our verifier was unavailable” with concrete numbers. Don’t log the token itself — it’s short-lived and useless, but tokens in logs are a habit we don’t want to institutionalize.
Does PLAYTCHA_FAIL_CLOSED change what the verifier returns?
No. It’s a customer-side env var. The Playtcha verifier returns exactly the same responses regardless. The env var only changes how your server reacts to the unavailable case. That deliberately keeps the policy decision in your code, where your security review can see it, instead of a vendor setting buried in a dashboard.
What about during scheduled maintenance?
Scheduled maintenance, once the alerting pipeline lands, is announced by email to project owners. During maintenance the verifier may be briefly unavailable; the fail-open contract behaves the same way regardless of whether the unavailability was planned. We don’t do hot-cutover deploys that take the verifier down — typical maintenance windows are sub-second drains rather than visible outages.
Related reading: the upstream “how the verify call works” explanation lives in CAPTCHA with Supabase, which walks the full token lifecycle through a real form. The compliance posture that this fail-open promise sits inside is documented in GDPR-compliant CAPTCHA checklist. And if you want the broader privacy story — why we can’t copy the reCAPTCHA “invisible telemetry” trick — see privacy-first CAPTCHAs explained.