Documentation
Everything you need to put WAU Captcha in front of a form or an API.
How it works
A captcha check takes three calls. Your page fetches a challenge with the public site key, the person answers it and gets a one-time token, and your server redeems the token with the secret key before accepting the request.
Your keys
- Site key (pk_…) — public. Used in the browser to fetch challenges.
- Secret key (sk_…) — private. Used by your server to redeem tokens. Never ship it to a browser.
- Create a key pair on the API keys page of the dashboard.
Quick start
- Create an API key in the dashboard and copy its site key and secret key.
- Add the widget, or <WauCaptcha /> in React, to your form. Once solved, the form sends a hidden field named wau-captcha-token.
- On your server, redeem that token with the secret key and reject the request when it fails.
Browser widget
The widget shows the challenge, a text box and two buttons, and loads a new challenge after a wrong answer. With the script build, any element with data-wau-captcha renders by itself.
<script src="https://cdn.jsdelivr.net/npm/@wau-captcha/client@1/dist/wau-captcha.global.js"></script>
<form action="/signup" method="post">
<input name="email" type="email" required />
<div data-wau-captcha data-site-key="pk_…" data-base-url="https://api.waucaptcha.com"></div>
<button type="submit">Sign up</button>
</form>Attributes: data-site-key and data-base-url (required), data-challenge-type (image_distort or motion_noise) and data-input-name (default wau-captcha-token).
With a bundler, render it yourself to get callbacks and your own labels:
npm install @wau-captcha/clientimport { renderWidget } from "@wau-captcha/client";
const widget = renderWidget(document.querySelector("#captcha"), {
siteKey: "pk_…",
baseUrl: "https://api.waucaptcha.com",
onSuccess(token) {
// Also in the hidden input "wau-captcha-token" of the surrounding form.
},
onError(error) {
console.error(error.code, error.message);
},
});React component
@wau-captcha/react gives you one component, <WauCaptcha />, for React 18 or later. Put it inside a form: once solved, the form sends the token in the hidden field wau-captcha-token.
npm install @wau-captcha/reactNext.js App Router
It works straight from a Server Component page; the server action of the form redeems the token with @wau-captcha/server.
import { WauCaptcha } from "@wau-captcha/react";
import { signUp } from "./actions";
export default function SignUpPage() {
return (
<form action={signUp}>
<input name="email" type="email" required />
<WauCaptcha siteKey="pk_…" baseUrl="https://api.waucaptcha.com" />
<button type="submit">Sign up</button>
</form>
);
}"use server";
import { WauCaptchaServer } from "@wau-captcha/server";
export async function signUp(formData: FormData) {
const captcha = new WauCaptchaServer({
secretKey: process.env.WAU_SECRET_KEY!,
baseUrl: "https://api.waucaptcha.com",
});
const result = await captcha.verifyToken(String(formData.get("wau-captcha-token") ?? ""));
if (!result.success) throw new Error("Please solve the captcha.");
// … create the account
}Modal mode
WauCaptchaMode.MODAL shows a button that opens the challenge in a dialog, which closes by itself once the answer is right. The challenge only loads when the dialog opens. Callbacks such as onSuccess need a Client Component.
"use client";
import { ChallengeType, WauCaptcha, WauCaptchaMode } from "@wau-captcha/react";
export function SignUpCaptcha() {
return (
<WauCaptcha
mode={WauCaptchaMode.MODAL}
challengeType={ChallengeType.MOTION_NOISE}
siteKey="pk_…"
baseUrl="https://api.waucaptcha.com"
onSuccess={(token) => console.log("solved", token)}
onError={(error) => console.error(error.code)}
/>
);
}Props
To start over, e.g. after the backend has redeemed the token, change the component's key. To match your brand, set --wau-captcha-primary, --wau-captcha-background or --wau-captcha-border on it or on an ancestor.
Custom UI in the browser
Use WauCaptchaClient to build your own interface. A challenge can be answered once: after a wrong answer, fetch a new one.
import { ChallengeType, WauCaptchaClient } from "@wau-captcha/client";
const captcha = new WauCaptchaClient({ siteKey: "pk_…", baseUrl: "https://api.waucaptcha.com" });
const challenge = await captcha.newChallenge(); // or { challengeType: ChallengeType.MOTION_NOISE }
image.src = challenge.imageUrl;
image.width = challenge.width;
image.height = challenge.height;
const result = await captcha.verify(challenge.id, input.value);
if (result.success) {
// Send result.token to your server with the form.
} else {
// Wrong answer: the challenge is used up, fetch a new one.
}Verify on a Node.js server
Redeem the token with @wau-captcha/server (Node.js 18 or later). A thrown WauCaptchaError means the token could not be checked; treat it as a failure.
npm install @wau-captcha/serverimport { ErrorCode, WauCaptchaServer } from "@wau-captcha/server";
const wau = new WauCaptchaServer({
secretKey: process.env.WAU_SECRET_KEY,
baseUrl: "https://api.waucaptcha.com",
});
app.post("/signup", async (req, res) => {
try {
const result = await wau.verifyToken(req.body["wau-captcha-token"]);
if (!result.success) return res.status(400).send("Please solve the captcha.");
} catch (error) {
if (error.code === ErrorCode.SECRET_KEY_INVALID) console.error("Check WAU_SECRET_KEY");
return res.status(503).send("Captcha check unavailable."); // fail closed
}
// … create the account
});Verify on a Java server
Redeem the token with com.waucaptcha:wau-captcha-sdk (Java 17 or later, no dependencies). The client is immutable and safe to share between threads.
<dependency>
<groupId>com.waucaptcha</groupId>
<artifactId>wau-captcha-sdk</artifactId>
<version>1.0.0</version>
</dependency>WauCaptchaClient wau = WauCaptchaClient.builder()
.baseUrl("https://api.waucaptcha.com")
.secretKey(System.getenv("WAU_SECRET_KEY"))
.build();
try {
if (!wau.verifyToken(request.getParameter("wau-captcha-token")).success()) {
// reject the request
}
} catch (WauCaptchaException e) {
// e.getCode(): SECRET_KEY_INVALID, NETWORK_ERROR, … — fail closed
}REST API
The SDKs call these endpoints; any HTTP client can call them too. Errors come back as JSON {code, reason, message, metadata}.
GET /challenge/new
Issues a challenge. Query: site_key (required), challenge_type (image_distort by default). image is base64; display it at width × height CSS pixels.
curl "https://api.waucaptcha.com/challenge/new?site_key=pk_…&challenge_type=image_distort"
{
"challenge_id": "0f9c…",
"challenge_type": "image_distort",
"image": "iVBORw0KGgo…",
"mime_type": "image/png",
"width": 200,
"height": 70,
"expires_in": 120
}POST /challenge/verify
Checks an answer. success is false after a wrong answer; the challenge is used up either way.
curl -X POST "https://api.waucaptcha.com/challenge/verify" \
-H "Content-Type: application/json" \
-d '{"challenge_id": "0f9c…", "answer": "a8Kq2"}'
{ "success": true, "token": "…", "expires_in": 120 }POST /siteverify
Redeems a token, once. Send the secret key as a bearer token. success is false when the token is unknown, expired, already used or issued for another key.
curl -X POST "https://api.waucaptcha.com/siteverify?token=$TOKEN" \
-H "Authorization: Bearer $WAU_SECRET_KEY"
{ "success": true, "challenge_type": "image_distort", "challenge_ts": "2026-09-23T08:15:02Z" }Challenge types
- image_distort — five characters (letters and digits) distorted in a PNG. The default.
- motion_noise — four characters (capital letters and digits, without O, 0, Q, I and 1) hidden in a looping animated GIF of moving dots.
Answers are not case-sensitive. The plan of your account decides which types a key may request. In the JavaScript SDKs, name them with ChallengeType.IMAGE_DISTORT and ChallengeType.MOTION_NOISE.
Limits and security
- Challenges and tokens expire after two minutes, and each works once.
- Allowed IP addresses: on a key's page, list the addresses or networks (such as 203.0.113.0/24) that may request challenges. With none listed, every address may; 0.0.0.0 also allows everyone.
- Resetting a secret key keeps the previous one valid for 24 hours, or until you revoke it, so servers can switch without downtime.
- Each account has a daily quota of successful verifications (1,000 on the free plan). Once it is used up, new challenges are refused until midnight (Asia/Ho_Chi_Minh).
- A key that fetches at least 200 challenges in a day but sends fewer than 20% of them to /challenge/verify ((/challenge/verify) ÷ (/challenge/new) under 20%) is taken for spam and suspended automatically.
Errors
In the JavaScript SDKs, compare error.code with the ErrorCode constants, e.g. ErrorCode.SITE_KEY_INVALID.