Nothing to click. Nothing for a solver farm to solve.
Most CAPTCHAs judge one request at a time. An attack sends thousands that each pass. SignalGate CAPTCHA judges every request against the traffic around it.
Free through 2026 · No card · One script on the page. One check and two logs on your server.
otp.send · 200 requests · one hourIllustration · simulated traffic
✓
allow
Nothing wrong with this one. A real device, an address nobody has reported, a form filled at human speed. Judged on its own, it passes — and so do the other 199.
Not yet judgedJudged alone: allowThe request being judged
Finished the actionNever finished — code sent, paid forBlocked before the code was sent
200 passed. 180 never entered the code. 180 codes paid for.
One pattern. 180 requests. 139 blocked before the code was sent.
Same request, no traffic around it: allow. The verdict was never about this request alone.
Watch the same 200 requests two ways. Judged one at a time, every one of them passes. Judged together, they are one operation.
Illustration, simulated traffic: eight of two hundred requests, judged one at a time by a typical CAPTCHA and together by SignalGate CAPTCHA, with what each went on to do.
#
request
CAPTCHA
SignalGate CAPTCHASG CAPTCHA
then
0046
req_0046
✓ pass
✓ allow
code never entered
0047
req_0047
✓ pass
✓ allow
code never entered
0048
req_0048
✓ pass
✕ block
not sent
0049
req_0049
✓ pass
✓ allow
code entered ← a real user
0050
req_0050
✓ pass
✕ block
not sent
0051
req_0051
✓ pass
✕ block
not sent
0052
req_0052
✓ pass
✕ block
not sent
0053
req_0053
✓ pass
✕ block
not sent
codes sent 41 of 180 (CAPTCHA: 180) · codes entered 20 · blocked before sending 139
Illustration · simulated traffic. Judged one at a time, every request passes. Judged together, they are one operation — and the ones that never finish are the tell.
“This is a completely new level of security. With this technology, you no longer have to worry about loopholes that fraudsters can exploit, because it's far ahead of anything they're using today.”
Ex Tech Lead · inDrive — integrated SignalGate CAPTCHA
01What you just watched
Looks at the traffic, not just the request.
Nothing about a single request changed. There were just enough of them to see the shape. Alone, it passes. Together, the shape appears. From there, the next request from that traffic is stopped.
Comparison
A typical CAPTCHA
SignalGate CAPTCHA
Looks at
One request
The request and the traffic around it
Decides on
What one request shows — an address, an answer, what the browser says about itself
What the device itself reports, and how the request fits your traffic. Neither is for sale.
Your users see
Something on your page — sometimes nothing to do, sometimes a puzzle. It depends on the vendor.
Nothing. There is no widget, no badge and nothing to click.
What a solved challenge buys an attacker
Typically, the request passes
Nothing — there is no challenge, so there is no solution to buy
Learns from
Mostly the vendor's own network
Your own traffic — which requests finish what they started, and which never do
Attack-level detection
Usually an enterprise plan
Included. Free for everyone through 2026.
What you get back
A pass/fail token
allow · dry_run_block · block — you decide what each one does
Attackers can buy a pass. They can't buy your traffic.
We won't quote a number we haven't measured on your traffic. Run the check with your workflow in dry run: every request is judged, every verdict lands in your dashboard, and nothing is enforced. Switch it on when the numbers are yours.
Install
One script. One check. Two logs.
A script on the page collects the device signals and encrypts them in the browser. Your server checks each request before the action, logs it once it succeeds, and logs the success that follows. The logs are what teach it your traffic.
01
Add the script to your form page.
One script tag, and one capture in your submit handler. The script collects the device signals and encrypts them in the browser before they leave it — it never reads your form values. Nothing renders: there is no widget, no badge and nothing for your users to do.
Put your Public key where the example says YOUR_TENANT_KEY. It's meant to be published. That's the whole browser side — One script on the page. One check and two logs on your server.
On Next.js, wrap the app in <SignalGateProvider> once — the install line on this tab shows the package — and call the hook where you submit. getPayload() never throws and never rejects — it resolves null on every failure path, so branch on it and decide what an unprotected submit means for you.
"use client";import{useSignalGate}from"@signalgate/nextjs";exportfunctionCheckoutForm(){const{getPayload,status}=useSignalGate();asyncfunctiononSubmit(e:React.FormEvent<HTMLFormElement>){e.preventDefault();constform=e.currentTarget;// Never throws, never rejects — resolves null on any failure.constpayload=awaitgetPayload();if(payload===null){// Fail-open: no signal this time (blocked CDN, timeout, SDK error).// Proceed, retry, or block — your policy; here we submit without it.}awaitfetch("/api/checkout",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({item:newFormData(form).get("item"),signalgate:payload,// ← four-field envelope, forward unchanged}),});}return(<formonSubmit={onSubmit}>{/* ...your fields... */}<buttontype="submit"disabled={status==="loading"}>Submit</button></form>);}
02
Check on your server, then log the action.
The order matters. Call check() at the top of your handler, before the action runs, and gate on action == "block". Then run your action. Then call log() — after it succeeded, never before and never on failure. check() records nothing on its own — it only answers. The log is what puts the request in your dashboard.
Each funnel point captures its own fresh payload — the check, the action log, and the success log. The nonce inside a payload is single-use, so a reused envelope comes back 422 REQUEST_REJECTED. On the protected form, capture twice and send them under two field names; the examples use signalgate and signalgate_log.
Why the logs aren't optional. The check reads what your traffic looks like. The logs are how it finds out. Without them there is nothing to learn from, no workflow to build, and the check keeps returning allow — see going live ↓.
Two keys, both from the dashboard
Public keyGoes in the browser script. Meant to be published.
API keyGoes on your server, where the examples say pk_live_.... Must not be published.
WhereDashboard → API keys. Both are created there; the onboarding card walks you through it.
The verdicts check can return, with the recommended handling for each.
action
score
What it means
What your code does
allow
0.0
No risk detected. Normal traffic.
Continue normally.
dry_run_block
0.5
A would-be block, in shadow mode. The request is allowed so you can see what enforcement would catch before you turn it on.
Allow, and treat it as a future block.
block
1.0
High-confidence fraud.
Refuse the action; show a generic error.
Treat the set as open — new values may appear over time. Match on the ones you handle and fall through safely for anything else.
If the check can't reach us, the client fails open — the check is treated as a pass and your form keeps working. We'd rather you lose a check than a signup. Every backend client does this by default.
$pip install signalgate
fromdatetimeimportdatetime,timezonefromflaskimportFlask,requestfromsignalgateimportClient,Event,EncryptedPayload,ServerErrorapp=Flask(__name__)client=Client(api_key="pk_live_...")defsg_event(body:dict,method:str,envelope:str="signalgate",custom:dict|None=None)->Event:returnEvent(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) ---defauthenticate(email:str,password:str):...# your credential check; returns a user or Nonedefissue_session(user)->str:...# your session issuance@app.post("/api/login")deflogin():# 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"))ifverdict.action=="block":return{"error":"request rejected"},403exceptServerErrorasexc:# 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 itselfuser=authenticate(body["email"],body["password"])ifnotuser: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 logclient.log(sg_event(body,method="login",envelope="signalgate_log"))return{"ok":True,"session":issue_session(user)}
$npm install @signalgate/node
importexpressfrom"express";import{Client}from"@signalgate/node";constapp=express();app.use(express.json());constclient=newClient({apiKey:"pk_live_..."});/** Build an event from one of THIS request's sealed envelopes. */functionsgEvent(req,method,envelope="signalgate",custom){return{userId:req.body.email,ip:req.headers["x-forwarded-for"]??req.socket.remoteAddress,method,timestamp:newDate().toISOString(),payload:req.body[envelope],// the browser envelope, verbatim...(custom?{custom}:{}),// omit custom when unused};}// --- your existing app logic (stubs for this example) ---asyncfunctionauthenticate(email,password){// your credential check; returns a user or null}asyncfunctionissueSession(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"constverdict=awaitclient.check(sgEvent(req,"login"));if(verdict.action==="block"){returnres.status(403).json({error:"request rejected"});}// 2. The protected action itselfconstuser=awaitauthenticate(req.body.email,req.body.password);if(!user)returnres.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 logclient.log(sgEvent(req,"login","signalgate_log"));res.json({ok:true,session:awaitissueSession(user)});});app.listen(3000);
$go get github.com/SignalGate/signalgate-go
packagemainimport("encoding/json""log""net/http""time"signalgate"github.com/SignalGate/signalgate-go")varclient*signalgate.Client// The browser envelope, forwarded verbatim by your frontend.typesgPayloadstruct{Encryptedstring`json:"encrypted"`Timestampint64`json:"timestamp"`Noncestring`json:"nonce"`V*int`json:"v"`}typeloginRequeststruct{Emailstring`json:"email"`Passwordstring`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.SignalGatesgPayload`json:"signalgate"`SignalGateLogsgPayload`json:"signalgate_log"`}// sgEvent builds an Event from one of THIS request's sealed envelopes.funcsgEvent(r*http.Request,email,methodstring,psgPayload,custommap[string]any)signalgate.Event{ip:=r.Header.Get("X-Forwarded-For")ifip==""{ip=r.RemoteAddr}returnsignalgate.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) ---funcauthenticate(email,passwordstring)bool{returntrue// your credential check}funcissueSession(emailstring)string{return"session-token"// your session issuance}funchandleLogin(whttp.ResponseWriter,r*http.Request){varreqloginRequestiferr:=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))iferr!=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)}elseifverdict.Action=="block"{http.Error(w,"request rejected",http.StatusForbidden)return}// 2. The protected action itselfif!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 Logclient.Log(sgEvent(r,req.Email,"login",req.SignalGateLog,nil))json.NewEncoder(w).Encode(map[string]any{"ok":true,"session":issueSession(req.Email),})}funcmain(){varerrerrorclient,err=signalgate.New("pk_live_...")iferr!=nil{log.Fatal(err)}deferclient.Close(5*time.Second)http.HandleFunc("POST /api/login",handleLogin)log.Fatal(http.ListenAndServe(":8080",nil))}
$composer require signalgate/signalgate-php
useSignalGate\Client;/** Build an event from one of THIS request's sealed envelopes. */functionsgEvent(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=newClient(['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'));echojson_encode(['ok'=>true,'session'=>issueSession($user)]);
nothing to install
# 1. check() BEFORE the protected action — gate on "block"curl-s-XPOSThttps://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 logcurl-XPOSThttps://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
}
}'
dry_run_block— let it through; this is what enforcement would have caught. Read these before you switch it on.
block— refuse the action and show a generic error. There is no challenge to render; solving one wouldn't change what it belongs to.
03
Log the success that follows — in its own handler.
The action you protect has a success that comes later, in a different request: the code entered, the signup confirmed, the deposit applied. Log that one too. It is the second half of the funnel and the thing your workflow learns from — a request that reaches this handler finished what it started.
The examples pair login with deposit; yours might be otp.send with otp.verified. The sg_event helper is the one from step 2; applyDeposit is your own logic.
This request carries its own fresh capture. Never reuse the envelope from the protected form — every funnel point captures again.
# ── Target-action handler: the downstream conversion ──@app.post("/api/deposit")defdeposit():body=request.get_json()ifnotapply_deposit(body["email"],body["amount"]):return{"error":"deposit failed"},402# Its OWN event from THIS request's fresh fingerprint payloadclient.log(sg_event(body,method="deposit",custom={"target_action":True}))return{"ok":True}
// ── Target-action handler: the downstream conversion ──app.post("/api/deposit",async(req,res)=>{constok=awaitapplyDeposit(req.body.email,req.body.amount);if(!ok)returnres.status(402).json({error:"deposit failed"});// Its OWN event from THIS request's fresh fingerprint payloadclient.log(sgEvent(req,"deposit",{target_action:true}));res.json({ok:true});});app.listen(3000);
typedepositRequeststruct{Emailstring`json:"email"`Amountfloat64`json:"amount"`SignalGatesgPayload`json:"signalgate"`}// sgEvent builds an Event from THIS request's own fingerprint payload.funcsgEvent(r*http.Request,email,methodstring,psgPayload,custommap[string]any)signalgate.Event{ip:=r.Header.Get("X-Forwarded-For")ifip==""{ip=r.RemoteAddr}returnsignalgate.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) ---funcauthenticate(email,passwordstring)bool{returntrue// your credential check}funcissueSession(emailstring)string{return"session-token"// your session issuance}funcapplyDeposit(emailstring,amountfloat64)bool{returntrue// your business logic; true on success}// ── Action handler: the moment you protect ──funchandleLogin(whttp.ResponseWriter,r*http.Request){varreqloginRequestiferr:=json.NewDecoder(r.Body).Decode(&req);err!=nil{http.Error(w,"bad request",http.StatusBadRequest)return}if!authenticate(req.Email,req.Password){http.Error(w,"invalid credentials",http.StatusUnauthorized)return}// Log AFTER the action succeededclient.Log(sgEvent(r,req.Email,"login",req.SignalGate,nil))json.NewEncoder(w).Encode(map[string]any{"ok":true,"session":issueSession(req.Email),})}// ── Target-action handler: the downstream conversion ──funchandleDeposit(whttp.ResponseWriter,r*http.Request){varreqdepositRequestiferr:=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 payloadclient.Log(sgEvent(r,req.Email,"deposit",req.SignalGate,map[string]any{"target_action":true}))json.NewEncoder(w).Encode(map[string]any{"ok":true})}funcmain(){varerrerrorclient,err=signalgate.New("pk_live_...")iferr!=nil{log.Fatal(err)}deferclient.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))}
// ── Target-action handler (deposit.php): the downstream conversion ──$client=newClient(['api_key'=>'pk_live_...']);$body=json_decode(file_get_contents('php://input'),true);$ok=applyDeposit($body['email'],$body['amount']);// your logicif(!$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',['target_action'=>true]));echojson_encode(['ok'=>true]);
# ── Target-action place: log AFTER the deposit succeeds ──# A NEW payload collected on the deposit request — never reuse the login envelopecurl-XPOSThttps://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 }
}'
Then open the dashboard. Your requests appear as they arrive — the first one seconds after your first form submit.
This isn't a drop-in field rename, because there's no field to rename. You delete the widget and its verify call, and you add two calls on the server instead.
Remove: the vendor's <script>, the container element (g-recaptcha / cf-turnstile / h-captcha), the hidden response field, and your siteverify call. Add: the SignalGate CAPTCHA script and one capture in your submit handler; check() before the action; log() after it; log() again where the success lands.
Two things get simpler on the way. Your form loses an element and a layout shift, and your server stops branching on a token that may be missing, expired or reused — a verdict is always returned, and an unreachable check fails open.
One step is new, and it's worth knowing before you start: a per-request CAPTCHA is protecting you the moment you paste it. Here you also create a workflow — it reads the traffic you've logged, returns its first results in minutes, and runs in dry run until you switch it on. See going live ↓.
One script on the page. One check and two logs on your server.
03Your users
Nothing to click. Nothing to look at.
What your visitors experience, what happens to a real person we're wrong about, and what we actually collect — in the order a buyer asks.
01 · What they see
Nothing to click. Nothing to look at.
There is no widget on your page, no checkbox, no puzzle, no badge in the corner and no iframe. Your form looks exactly the way you built it, and it submits exactly as fast. Every request is checked before the action runs — your visitor never learns that anything happened.
no widgetno badgeno layout shift
02 · When we're wrong
When we're wrong about someone.
Every verdict is yours to act on — the check returns a verdict, your code decides what it means. If you're not sure yet, run the workflow in dry run and enforce nothing while you read. When you do enforce and we get one wrong, the request is in your dashboard with its verdict, and what your visitor sees is whatever your form shows — we recommend a plain message with a way to reach you, never a spinner.
dry_run_block · nothing enforced
03 · What we look at
What we look at, and what we won't publish.
Every request is judged on two things at once: what the device itself reports, and how the request fits the traffic around it right now. The script reads browser and device signals and encrypts them before they leave the page; what reaches your server is a four-field envelope — encrypted, timestamp, nonce, v — that you forward unchanged. What we do with it is in /legal/privacy §2. We don't publish the individual signals or the thresholds: the people who most want that list are the ones it stops. If you need it for a security review, ask — we answer questionnaires under NDA.
Addresses, solved challenges and browser profiles are sold by the unit. SignalGate CAPTCHA decides on what isn't: the device as it really is, and whether the request fits the traffic around it.
Bought.
Residential addresses by the gigabyte. Solved challenges by the thousand. Browser profiles by the month. An attack is a shopping list, and a typical CAPTCHA checks only things on it.
Dressed up.
One device can be made to look ordinary. Kept up across thousands of devices, for hours, next to your real users, the story stops holding together.
Not for sale.
Every request is judged against the traffic around it — on your site, right now and over time. Your real users set the standard, and they do the one thing an attack can't afford to: finish.
You can buy a thousand devices. You can't buy a thousand devices that finish what they started.
solved on someone else's device — and here, there is nothing to solve
~$1 / 1,000
passes
A browser that looks like someone else's
one device at a time
from ~$19 / mo
passes
Real phones, by the rack
real devices, real browsers
from ~$1,000
passes
Per request that passes every check — at attack volumeunder a cent
Not on any list
A thousand devices that agree with each other, for hours
one can be dressed up; a thousand can't stay consistent
no seller
Fitting the traffic around it
on your site, right now and over time — your real users set it
no seller
Finishing what it started
a code entered, a signup confirmed
no seller
Everything on the list was bought. The decision rests on what isn't.
otp.send →⊘ blockfor the operation, not one request
public list prices from proxy, solver, anti-detect and device-farm services, rounded · september 2026 · the per-request figure is derived from those prices at attack volume · they move; the last three lines don't.
The attacker's price list, an illustration: a residential address from about two dollars per gigabyte, a solved challenge about one dollar per thousand, a look-alike browser profile from about nineteen dollars a month, real phones by the rack from about a thousand dollars — each passes a per-request check; under a cent per request at attack volume. Not on any list, no seller: a thousand devices that agree with each other for hours; fitting the traffic around the request; finishing what it started. Verdict: block, for the operation, not one request.
Fingerprinting catches one device pretending to be many. SignalGate catches the opposite — many genuinely different devices working as one operation.
Both calls go in from the first request. What happens next is three short steps, and the last one is yours.
It starts reading immediately.
Every request lands in your dashboard with its device and its context from the first form submit. The check is already in your code; until there's a workflow to consult, it returns allow.
You create a workflow, and it reads what you've got.
Pick the action to protect and the success that follows it — a code sent, then a code entered — and log both. The first results come back in minutes; the full pass finishes inside the hour. It reads the window of traffic you already have, so the more it can see, the sharper it gets — but it doesn't wait for four days to have an opinion.
You read the dry run, then you turn it on.
In dry run every request gets a real verdict and nothing is enforced — the dashboard shows you exactly what enforcement would have caught, on your own forms. Switch it on when the numbers are yours.
Needs the attack's traffic, not yours.
A quiet form takes longer to read than a busy one — and an attack is the fastest thing there is to read, because for a while the attack is most of the traffic.
signalgate.ai/dashboardIllustration · your dashboard on the first day
Public key sg_pub_••••••••7f2aAPI key pk_live_••••••••••••
<script src="https://sdk.signalgate.ai/v0.3.3/index.global.js"></script>
const fp = new SignalGate.Fingerprint({ key: "sg_pub_••••••••7f2a" });
Waiting for your first request…
otp.send · req_0001allow · 12 s ago
No workflow yet — every verdict is allow until you create one.
Reading your traffic…
workflow otp.send → otp.verifieddry run · nothing enforced
1,284allow
1,201 finished
743dry_run_block
9 finished
0block
not enforced yet
Every verdict is in your dashboard before any of them is enforced. Counts in state 3 are illustrative placeholders — not a measurement.
06Pricing
Includes what others sell as enterprise.
Watching for coordinated attacks is usually an enterprise plan behind a sales call. SignalGate CAPTCHA includes it for everyone, on the free plan.
A verdict on every request
Attack-level detection, not per-request scoring
The part that's usually behind a sales call
Open beta
SignalGate CAPTCHA
Everything the check does. No card.
Free
Until December 31, 2026. No card required.
We haven't set free-tier limits yet. We'll publish them before we enforce them, and email your account first.
A verdict on every request
Attack-level detection included
Learns from your own traffic
Dry run before you enforce
Same dashboard, same account as SignalGate antifraud
Paid plans arrive in 2027. We'll publish them, and email you at least 30 days before anything changes for your account.
Live today
The account, the dashboard, the events log and analytics
Processing in the EU — AWS Frankfurt
The browser SDK and the Next.js package
The backend clients on PyPI, npm, Go, Maven and Packagist
POST /v0/check and POST /v0/log — the two calls on this page
In progress
Field-name compatibility helpers for reCAPTCHA and Turnstile ports
A published accessibility audit
A public status page
Paid plans, 2027
07Questions
The ones we get asked.
We won't quote a number we haven't measured on your traffic — and we haven't measured yours yet. Any CAPTCHA can publish a block rate from its own network; it says nothing about your forms. So the offer is the measurement, not the number: run your workflow in dry run. Every request gets a real verdict, nothing is enforced, and the dashboard shows what enforcement would have caught. Switch it on when the numbers are yours.
One device, yes — any single signal can be faked, and every CAPTCHA that judges one request at a time is beaten that way. That's why the request is judged together with the traffic around it. An attack has to fake thousands of different devices consistently, over time, while behaving like real visitors who finish what they started. Residential proxies, solved tokens and solving farms are all things you can buy. A consistent population of fake devices that also converts is not.
A solved challenge is a commodity priced by the thousand. It buys nothing here, because there is no challenge to solve — the verdict never depended on one.
The address is one input. A clean address changes nothing about the device or about how the request fits the traffic around it.
Fingerprinting catches one device pretending to be many. SignalGate catches the opposite — many genuinely different devices working as one operation. The device is an input, not the verdict.
A rate limit counts requests. This reads their shape: thousands of genuinely different devices doing the same thing and almost none of them finishing it. A real launch burst is also a lot of requests — and it finishes, so it passes.
They can, and it costs them the thing the attack is for. Finishing means entering the code, confirming the signup, completing the payment — the expense the volume exists to avoid. The more of it they buy back, the less the operation earns.
Nothing. There is no checkbox, no puzzle, no badge and no iframe. Your form looks and submits exactly as you built it. The script collects device signals and encrypts them in the browser; your server asks us for a verdict before the action runs. Your visitor never learns that anything happened — including the ones we block, who just see whatever your form shows on a refused request.
You can be live the same visit. Both calls go in from the first request and every request lands in your dashboard immediately. Then you create a workflow — pick the action to protect and the success that follows it — and it reads the traffic you've already logged: first results in minutes, the full pass inside the hour. It runs in dry run until you switch it on, so you see what enforcement would have caught on your own forms before anyone is blocked by it. The one thing it can't do is have an opinion about traffic it hasn't seen, so a busy form is ready sooner than a quiet one — and a form under attack is the fastest of all.
A request with no payload is a malformed request — check() returns BAD_REQUEST and your handler decides what to do with it. The browser SDK is the input; the server call is the gate. A request that skips both skips your integration, not ours.
Every backend client fails open by default: the check is treated as a pass and your form keeps working. We'd rather you lose a check than a signup. There's no SLA during the free period; we'd rather say that than invent a number.
The browser script collects device and browser signals — the kind of thing your browser reports about itself — and encrypts them in the browser before they leave the device, so they're never exposed on the wire or to third parties; they're decrypted only on our side, to decide on requests. Processing happens in AWS Frankfurt. Events are retained for 12 months. You are the controller and SignalGate is the processor; the DPA and subprocessor list are public, and /legal/privacy §2 has a paragraph you can paste into your own policy.
There is nothing on your page to be accessible or inaccessible — no challenge, no widget, no focus trap, no timing requirement, nothing to hear or type. Whatever your form does today, it keeps doing. What we'd ask of your form is the same thing we'd ask of any refused request: show a plain message with a way to reach you, never a spinner.
Remove the widget, its script and your siteverify call; add the script, one capture, one check and two logs. The switching block in the install lists exactly what goes and what comes. One step is new: you create a workflow, and it runs in dry run until you switch it on.
Free for everyone through 2026. Paid plans arrive in 2027; we'll publish them, and we'll email you at least 30 days before anything changes for your account.
SignalGate CAPTCHA protects forms — signup, login, OTP, checkout. The antifraud API is for actions that don't have a form in front of them. Same account, same dashboard: pick the one that matches the problem you have today, and add the other whenever you need it.
See the whole attack. Free through 2026.
One script on the page. One check and two logs on your server. Your traffic in your dashboard in minutes.