Integrate SignalGate CAPTCHA
One script tag on the page. On the server: check() before the action you protect, log() after it succeeds, and a second log() after the success that follows it. Then one workflow in the dashboard.
Replacing another CAPTCHA — remove
- the widget script tag
- the container element (
g-recaptcha,cf-turnstile,h-captcha) - the hidden response field
- the
siteverifyrequest on your server
Add
- one script tag
- one capture per server call — two for a gated action
check()before the actionlog()after the action, and after the success that follows it
The whole integration
Keys: /settings/api-keys — Public Keys for the browser, API Keys for the server.
Browser · the form page
<script src="https://sdk.signalgate.ai/v0.3.3/index.global.js"></script>
<script type="module">
const fp = new SignalGate.Fingerprint({
key: "YOUR_TENANT_KEY"
});
await fp.start(); // warm detectors once
const form = document.getElementById("login-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Two captures: each call your server makes consumes its own nonce.
const { payload: signalgate } = await fp.get(); // → check()
const { payload: signalgate_log } = await fp.get(); // → log()
await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: form.email.value,
password: form.password.value,
signalgate, // ← four-field envelope, treat as opaque
signalgate_log, // ← the second, fresh envelope
}),
});
});
</script>"use client";
import { useSignalGate } from "@signalgate/nextjs";
export function LoginForm() {
const { getPayload, status } = useSignalGate();
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
// Two captures: each call your server makes consumes its own nonce.
// Never throws, never rejects — each resolves null on any failure.
const signalgate = await getPayload(); // → check()
const signalgate_log = await getPayload(); // → log()
if (signalgate === null || signalgate_log === null) {
// Fail-open: no signal this time (blocked CDN, timeout, SDK error).
// Proceed, retry, or block — your policy; here we submit without it.
}
await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: form.get("email"),
password: form.get("password"),
signalgate, // ← four-field envelope, forward unchanged
signalgate_log, // ← the second, fresh envelope
}),
});
}
return (
<form onSubmit={onSubmit}>
{/* ...your fields... */}
<button type="submit" disabled={status === "loading"}>
Log in
</button>
</form>
);
}Server · the endpoint the form posts to
from datetime import datetime, timezone
from flask import Flask, request
from signalgate import Client, Event, EncryptedPayload, ServerError
app = Flask(__name__)
client = Client(api_key="pk_live_...")
def sg_event(body: dict, method: str, envelope: str = "signalgate",
custom: dict | None = None) -> Event:
return Event(
user_id=body["email"],
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
method=method,
timestamp=datetime.now(timezone.utc).isoformat(),
payload=EncryptedPayload(**body[envelope]),
custom=custom,
)
# --- your existing app logic (stubs for this example) ---
def authenticate(email: str, password: str):
... # your credential check; returns a user or None
def issue_session(user) -> str:
... # your session issuance
@app.post("/api/login")
def login():
# The gated request body carries TWO sealed envelopes from the browser
# SDK: "signalgate" (for the check) and "signalgate_log" (a second fresh
# capture, for the post-success log)
body = request.get_json()
# 1. check() BEFORE the protected action — gate on "block"
try:
verdict = client.check(sg_event(body, method="login"))
if verdict.action == "block":
return {"error": "request rejected"}, 403
except ServerError as exc:
# With fail_open on (the default) this is a 4xx — a bug in the
# request, not a fraud signal. Surface it loudly; don't block the user.
app.logger.error("signalgate check failed: %s", exc)
# 2. The protected action itself
user = authenticate(body["email"], body["password"])
if not user:
return {"error": "invalid credentials"}, 401
# 3. log() AFTER the action succeeded — a SECOND event from
# "signalgate_log": each API call consumes its payload's single-use
# nonce — never reuse the checked payload for the log
client.log(sg_event(body, method="login", envelope="signalgate_log"))
return {"ok": True, "session": issue_session(user)}
# ── Target-action handler: the downstream conversion ──
@app.post("/api/deposit")
def deposit():
body = request.get_json()
if not apply_deposit(body["email"], body["amount"]):
return {"error": "deposit failed"}, 402
# Its OWN event from THIS request's fresh fingerprint payload
client.log(sg_event(body, method="deposit", custom={"target_action": True}))
return {"ok": True}
def apply_deposit(email: str, amount: float) -> bool:
... # your business logic; True on successimport express from "express";
import { Client } from "@signalgate/node";
const app = express();
app.use(express.json());
const client = new Client({ apiKey: "pk_live_..." });
/** Build an event from one of THIS request's sealed envelopes. */
function sgEvent(req, method, envelope = "signalgate", custom) {
return {
userId: req.body.email,
ip: req.headers["x-forwarded-for"] ?? req.socket.remoteAddress,
method,
timestamp: new Date().toISOString(),
payload: req.body[envelope], // the browser envelope, verbatim
...(custom ? { custom } : {}), // omit custom when unused
};
}
// --- your existing app logic (stubs for this example) ---
async function authenticate(email, password) {
// your credential check; returns a user or null
}
async function issueSession(user) {
// your session issuance
}
app.post("/api/login", async (req, res) => {
// The gated request body carries TWO sealed envelopes from the browser SDK:
// "signalgate" (for the check) and "signalgate_log" (a second fresh
// capture, for the post-success log)
// 1. check() BEFORE the protected action — gate on "block"
const verdict = await client.check(sgEvent(req, "login"));
if (verdict.action === "block") {
return res.status(403).json({ error: "request rejected" });
}
// 2. The protected action itself
const user = await authenticate(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "invalid credentials" });
// 3. log() AFTER the action succeeded — a SECOND event from
// "signalgate_log": each API call consumes its payload's single-use
// nonce — never reuse the checked payload for the log
client.log(sgEvent(req, "login", "signalgate_log"));
res.json({ ok: true, session: await issueSession(user) });
});
// ── Target-action handler: the downstream conversion ──
app.post("/api/deposit", async (req, res) => {
const ok = await applyDeposit(req.body.email, req.body.amount);
if (!ok) return res.status(402).json({ error: "deposit failed" });
// Its OWN event from THIS request's fresh fingerprint payload
client.log(sgEvent(req, "deposit", "signalgate", { target_action: true }));
res.json({ ok: true });
});
async function applyDeposit(email, amount) {
// your business logic; true on success
}
app.listen(3000);package main
import (
"encoding/json"
"log"
"net/http"
"time"
signalgate "github.com/SignalGate/signalgate-go"
)
var client *signalgate.Client
// The browser envelope, forwarded verbatim by your frontend.
type sgPayload struct {
Encrypted string `json:"encrypted"`
Timestamp int64 `json:"timestamp"`
Nonce string `json:"nonce"`
V *int `json:"v"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
// The gated request carries TWO sealed envelopes from the browser SDK:
// "signalgate" for the Check, "signalgate_log" (a second fresh capture)
// for the post-success Log.
SignalGate sgPayload `json:"signalgate"`
SignalGateLog sgPayload `json:"signalgate_log"`
}
// sgEvent builds an Event from one of THIS request's sealed envelopes.
func sgEvent(r *http.Request, email, method string, p sgPayload,
custom map[string]any) signalgate.Event {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return signalgate.Event{
UserID: email,
IP: ip,
Method: method,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Payload: signalgate.EncryptedPayload{
Encrypted: p.Encrypted,
Timestamp: p.Timestamp,
Nonce: p.Nonce,
V: p.V,
},
Custom: custom,
}
}
// --- your existing app logic (stubs for this example) ---
func authenticate(email, password string) bool {
return true // your credential check
}
func issueSession(email string) string {
return "session-token" // your session issuance
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// 1. Check BEFORE the protected action — gate on "block"
verdict, err := client.Check(sgEvent(r, req.Email, "login", req.SignalGate, nil))
if err != nil {
// With fail-open on (the default) this is a 4xx *ServerError — a bug
// in the request. Surface it loudly; it is not a fraud signal.
log.Printf("signalgate check failed: %v", err)
} else if verdict.Action == "block" {
http.Error(w, "request rejected", http.StatusForbidden)
return
}
// 2. The protected action itself
if !authenticate(req.Email, req.Password) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
// 3. Log AFTER the action succeeded — a SECOND event from
// "signalgate_log": each API call consumes its payload's single-use
// nonce — never reuse the checked payload for the Log
client.Log(sgEvent(r, req.Email, "login", req.SignalGateLog, nil))
json.NewEncoder(w).Encode(map[string]any{
"ok": true, "session": issueSession(req.Email),
})
}
// ── Target-action handler: the downstream conversion ──
func handleDeposit(w http.ResponseWriter, r *http.Request) {
var req depositRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !applyDeposit(req.Email, req.Amount) {
http.Error(w, "deposit failed", http.StatusPaymentRequired)
return
}
// Its OWN event from THIS request's fresh fingerprint payload
client.Log(sgEvent(r, req.Email, "deposit", req.SignalGate,
map[string]any{"target_action": true}))
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
type depositRequest struct {
Email string `json:"email"`
Amount float64 `json:"amount"`
SignalGate sgPayload `json:"signalgate"`
}
func applyDeposit(email string, amount float64) bool {
return true // your business logic; true on success
}
func main() {
var err error
client, err = signalgate.New("pk_live_...")
if err != nil {
log.Fatal(err)
}
defer client.Close(5 * time.Second)
// ServeMux method patterns need Go 1.22+ (the SDK's floor).
http.HandleFunc("POST /api/login", handleLogin)
http.HandleFunc("POST /api/deposit", handleDeposit)
log.Fatal(http.ListenAndServe(":8080", nil))
}import ai.signalgate.sdk.*;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class FunnelController {
private final Client client = Client.builder("pk_live_...").build();
public record SgPayload(String encrypted, long timestamp, String nonce, Integer v) {}
// The gated request carries TWO sealed envelopes from the browser SDK:
// "signalgate" for the check, "signalgate_log" (a second fresh capture)
// for the post-success log.
public record LoginRequest(String email, String password,
SgPayload signalgate,
@JsonProperty("signalgate_log") SgPayload signalgateLog) {}
/** Build an Event from one of THIS request's sealed envelopes. */
private Event sgEvent(HttpServletRequest http, String userId, String method,
SgPayload p, Map<String, Object> custom) {
String forwarded = http.getHeader("X-Forwarded-For");
String ip = forwarded != null ? forwarded : http.getRemoteAddr();
return new Event(userId, ip, method, Instant.now().toString(),
new EncryptedPayload(p.encrypted(), p.timestamp(), p.nonce(), p.v()),
custom);
}
// --- your existing app logic (stubs for this example) ---
private Object authenticate(String email, String password) {
return null; // your credential check; returns a user or null
}
private String issueSession(Object user) {
return "session"; // your session issuance
}
@PostMapping("/api/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest req,
HttpServletRequest http) {
// 1. check() BEFORE the protected action — gate on "block"
CheckResult verdict = client.check(
sgEvent(http, req.email(), "login", req.signalgate(), null));
if ("block".equals(verdict.action())) {
return ResponseEntity.status(403).body(Map.of("error", "request rejected"));
}
// 2. The protected action itself
Object user = authenticate(req.email(), req.password());
if (user == null) {
return ResponseEntity.status(401).body(Map.of("error", "invalid credentials"));
}
// 3. log() AFTER the action succeeded — a SECOND event from
// "signalgate_log": each API call consumes its payload's single-use
// nonce — never reuse the checked payload for the log
client.log(sgEvent(http, req.email(), "login", req.signalgateLog(), null));
return ResponseEntity.ok(Map.of("ok", true, "session", issueSession(user)));
}
// ── Target-action handler: the downstream conversion ──
@PostMapping("/api/deposit")
public ResponseEntity<Map<String, Object>> deposit(@RequestBody DepositRequest req,
HttpServletRequest http) {
if (!applyDeposit(req.email(), req.amount())) {
return ResponseEntity.status(402).body(Map.of("error", "deposit failed"));
}
// Its OWN event from THIS request's fresh fingerprint payload
client.log(sgEvent(http, req.email(), "deposit", req.signalgate(),
Map.of("target_action", true)));
return ResponseEntity.ok(Map.of("ok", true));
}
public record DepositRequest(String email, double amount, SgPayload signalgate) {}
private boolean applyDeposit(String email, double amount) {
return true; // your business logic; true on success
}
}use SignalGate\Client;
/** Build an event from one of THIS request's sealed envelopes. */
function sgEvent(
array $body,
string $method,
string $envelope = 'signalgate',
?array $custom = null,
): array {
$event = [
'user_id' => $body['email'],
'ip' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'],
'method' => $method,
'timestamp' => date('c'),
'payload' => $body[$envelope], // the browser envelope, verbatim
];
if ($custom !== null) {
$event['custom'] = $custom; // omit custom when unused
}
return $event;
}
// ── Action handler (login.php) ──
$client = new Client(['api_key' => 'pk_live_...']);
$body = json_decode(file_get_contents('php://input'), true);
// The gated request body carries TWO sealed envelopes from the browser SDK:
// "signalgate" (for the check) and "signalgate_log" (a second fresh
// capture, for the post-success log)
// 1. check() BEFORE the protected action — gate on "block"
$verdict = $client->check(sgEvent($body, 'login'));
if ($verdict->action === 'block') {
http_response_code(403);
exit(json_encode(['error' => 'request rejected']));
}
// 2. The protected action itself
$user = authenticate($body['email'], $body['password']);
if ($user === null) {
http_response_code(401);
exit(json_encode(['error' => 'invalid credentials']));
}
// 3. log() AFTER the action succeeded — a SECOND event from
// "signalgate_log": each API call consumes its payload's single-use
// nonce — never reuse the checked payload for the log
$client->log(sgEvent($body, 'login', 'signalgate_log'));
echo json_encode(['ok' => true, 'session' => issueSession($user)]);
// ── Target-action handler (deposit.php): the downstream conversion ──
$client = new Client(['api_key' => 'pk_live_...']);
$body = json_decode(file_get_contents('php://input'), true);
$ok = applyDeposit($body['email'], $body['amount']); // your logic
if (!$ok) {
http_response_code(402);
exit(json_encode(['error' => 'deposit failed']));
}
// Its OWN event from THIS request's fresh fingerprint payload
$client->log(sgEvent($body, 'deposit', 'signalgate', ['target_action' => true]));
echo json_encode(['ok' => true]);# 1. check() BEFORE the protected action — gate on "block"
curl -s -X POST https://api.signalgate.ai/v0/check \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"ip": "203.0.113.42",
"method": "login",
"timestamp": "2026-04-01T13:08:50+00:00",
"payload": {
"encrypted": "<base64 blob from the frontend SDK>",
"timestamp": 1748102400000,
"nonce": "aZ19bCde3fGhI4jK",
"v": 2
}
}' | jq -r '.data.action'
# -> "allow" | "dry_run_block" | "block"
# On "block": reject the request. Otherwise:
# 2. Run the protected action (your login) ...
# 3. log() AFTER the action succeeds — with the SECOND sealed envelope the
# frontend captured for this request: each API call consumes its payload's
# single-use nonce — never reuse the checked payload for the log
curl -X POST https://api.signalgate.ai/v0/log \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"ip": "203.0.113.42",
"method": "login",
"timestamp": "2026-04-01T13:08:52+00:00",
"payload": {
"encrypted": "<a second, fresh base64 blob from the frontend SDK>",
"timestamp": 1748102402000,
"nonce": "qX42mNp7rEwT9kLa",
"v": 2
}
}'
# ── Target-action place: log AFTER the deposit succeeds ──
# A NEW payload collected on the deposit request — never reuse the login envelope
curl -X POST https://api.signalgate.ai/v0/log \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"ip": "203.0.113.42",
"method": "deposit",
"timestamp": "2026-04-01T13:12:02+00:00",
"payload": {
"encrypted": "<a fresh base64 blob from the deposit request>",
"timestamp": 1748102592000,
"nonce": "qW3eRt56yUiO7pAs",
"v": 2
},
"custom": { "target_action": true }
}'- 1The script loads once per page.
keyis the Public key. - 2
fp.get()returns{ encrypted, timestamp, nonce, v: 2 }. Call it once per server call — twice for a gated action. - 3Send the envelopes as JSON fields, unchanged.
- 4
check()before the action. Gate on"block". Returnsallowuntil a workflow is live. - 5The action runs unchanged.
- 6
log()after the action succeeds, from the second envelope. - 7The success that follows (
target_method) gets its ownlog()in its own handler, from that request's envelope.
Five things the code doesn't show
check()returnsallowuntil a workflow is live.The check is wired and events are arriving. Verdicts stay
allowuntil you create and start a workflow (below).Every server call needs its own capture.
Each envelope carries a single-use nonce.
check()consumes one;log()needs a second. Reusing an envelope returns422 REQUEST_REJECTED.Both logs are required — one is not a partial integration, it is a broken one.
A workflow measures conversion: how often the action is followed by the success that follows it. Log only the action and there is no conversion to measure, so the workflow cannot tell real traffic from an attack and its verdicts are worthless.
check()itself records nothing — the two logs are the data.The verdict is about the surrounding traffic, not the request.
A request is judged against the traffic arriving with it. The same request can return
allowon its own andblockas part of a pattern. There is no per-request score.An unreachable check fails open.
If
/v0/checkdoes not answer within the timeout, the SDK returnsallowwithfailed_open: trueand the action proceeds.
Going live
Quick Start Checkpoints
The dashboard tracks five checkpoints: email verified, two keys, first log event, first workflow, first check. ↻ Refresh re-checks.
Keys —
/settings/api-keysPublic Keys → generate; use it as
keyin the browser script. API Keys → create; use it asAuthorization: Bearer pk_live_…on the server. Shown once.Send events
Deploy. The first
log()completes checkpoint three. Verdicts remainallowuntil a workflow exists. Accumulate a few hundred events before the first dry run.New flow — on the dashboard
namethe workflow's URL key methodthe action you protect — the request you check(), e.g.login,otp.sendtarget_methodthe success that follows it, e.g. otp.verifiedanalysis_window (h)96 — how far back it reads. A lookback, not a wait conversion threshold (%)0.5 — leave the default min group size50 — leave the default actionset dry_run_block. The default isblock— see the warning belowenabledon The workflow is created as a draft. Nothing is enforced.
Dry Run → Dry Run Results
Status: dry run in progress → dry run complete. First results within minutes; the full pass within the hour. The results strip shows the action and target action, current and predicted conversion, and the share that would have been blocked.
Start
The workflow becomes active. With
action = dry_run_block, matched requests returndry_run_blockand nothing is enforced; the rest returnallow. Review in Events (filter by verdict) and Analytics. Stop pauses; Rerun Dry Run re-runs a paused workflow.Enforce — Edit →
action=block→ savecheck()now returnsblockfor matched requests and your handler stops the action.
action is block. Start on a never-run draft runs the dry run and promotes the workflow automatically on success — "with action block it will enforce as soon as it goes live" (the Start button's hint). Set dry_run_block first to observe before enforcing.Reference
Verdicts
| action | score | meaning |
|---|---|---|
allow | 0.0 | Proceed. |
dry_run_block | 0.5 | Would have blocked. Not enforced. |
block | 1.0 | Stop the action. |
The set is open. Gate on block only.
Errors
| code | status | meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing or wrong API key. |
BAD_REQUEST | 400 | Malformed body. |
INVALID_PAYLOAD | 400 | The envelope cannot be read. Forward it unchanged. |
REQUEST_REJECTED | 422 | Nonce already used. Capture a fresh envelope. |
Every response is { ok, data, error }. No 429, no 202.
Keys
| key | where | handling |
|---|---|---|
| Public key | browser — key: in the script | Public by design. |
API key pk_live_… | server — Authorization: Bearer | Never in a page, bundle or repository. |
Envelope
interface EncryptedPayload {
encrypted: string; // base64 envelope, opaque
timestamp: number; // ms since epoch
nonce: string; // unique per call, server-deduplicated
v: 2; // envelope format version (the SDK emits v=2)
}
// fp.get() returns
interface FingerprintResult {
payload: EncryptedPayload;
raw?: FingerprintData; // only present when debug: true
}Install
| Browser | <script src="https://sdk.signalgate.ai/v0.3.3/index.global.js"></script> | |
| Next.js | npm install @signalgate/nextjs | |
| Python | pip install signalgate | |
| Node.js | npm install @signalgate/node | |
| Go | go get github.com/SignalGate/signalgate-go | |
| Java | implementation 'ai.signalgate:backend-sdk:0.1.0' | |
| PHP | composer require signalgate/signalgate-php |
<dependency>
<groupId>ai.signalgate</groupId>
<artifactId>backend-sdk</artifactId>
<version>0.1.0</version>
</dependency># No install — any HTTP client works. Smoke-test your key with an empty body:
curl -i https://api.signalgate.ai/v0/check \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{}'
# 401 -> check your key | 400 BAD_REQUEST -> key OK, now send a real body