SignalGate CAPTCHA · Docs

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 siteverify request on your server

Add

  • one script tag
  • one capture per server call — two for a gated action
  • check() before the action
  • log() after the action, and after the success that follows it

The whole integration

Keys: /settings/api-keysPublic 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>

Server · the endpoint the form posts to

pip install signalgate
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 success
  1. 1The script loads once per page. key is the Public key.
  2. 2fp.get() returns { encrypted, timestamp, nonce, v: 2 }. Call it once per server call — twice for a gated action.
  3. 3Send the envelopes as JSON fields, unchanged.
  4. 4check() before the action. Gate on "block". Returns allow until a workflow is live.
  5. 5The action runs unchanged.
  6. 6log() after the action succeeds, from the second envelope.
  7. 7The success that follows (target_method) gets its own log() in its own handler, from that request's envelope.

Five things the code doesn't show

  1. check() returns allow until a workflow is live.

    The check is wired and events are arriving. Verdicts stay allow until you create and start a workflow (below).

  2. Every server call needs its own capture.

    Each envelope carries a single-use nonce. check() consumes one; log() needs a second. Reusing an envelope returns 422 REQUEST_REJECTED.

  3. 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.

  4. 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 allow on its own and block as part of a pattern. There is no per-request score.

  5. An unreachable check fails open.

    If /v0/check does not answer within the timeout, the SDK returns allow with failed_open: true and the action proceeds.

Going live

  1. Quick Start Checkpoints

    The dashboard tracks five checkpoints: email verified, two keys, first log event, first workflow, first check. ↻ Refresh re-checks.

  2. Keys — /settings/api-keys

    Public Keys → generate; use it as key in the browser script. API Keys → create; use it as Authorization: Bearer pk_live_… on the server. Shown once.

  3. Send events

    Deploy. The first log() completes checkpoint three. Verdicts remain allow until a workflow exists. Accumulate a few hundred events before the first dry run.

  4. New flow — on the dashboard

    namethe workflow's URL key
    methodthe action you protect — the request you check(), e.g. login, otp.send
    target_methodthe success that follows it, e.g. otp.verified
    analysis_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 is block — see the warning below
    enabledon

    The workflow is created as a draft. Nothing is enforced.

  5. Dry RunDry Run Results

    Status: dry run in progressdry 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.

  6. Start

    The workflow becomes active. With action = dry_run_block, matched requests return dry_run_block and nothing is enforced; the rest return allow. Review in Events (filter by verdict) and Analytics. Stop pauses; Rerun Dry Run re-runs a paused workflow.

  7. Enforce — Editaction = block → save

    check() now returns block for matched requests and your handler stops the action.

The form's default 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

actionscoremeaning
allow0.0Proceed.
dry_run_block0.5Would have blocked. Not enforced.
block1.0Stop the action.

The set is open. Gate on block only.

Errors

codestatusmeaning
UNAUTHORIZED401Missing or wrong API key.
BAD_REQUEST400Malformed body.
INVALID_PAYLOAD400The envelope cannot be read. Forward it unchanged.
REQUEST_REJECTED422Nonce already used. Capture a fresh envelope.

Every response is { ok, data, error }. No 429, no 202.

Keys

keywherehandling
Public keybrowser — key: in the scriptPublic by design.
API key pk_live_…server — Authorization: BearerNever in a page, bundle or repository.

Envelope

TypeScript
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.jsnpm install @signalgate/nextjs
Pythonpip install signalgate
Node.jsnpm install @signalgate/node
Gogo get github.com/SignalGate/signalgate-go
Javaimplementation 'ai.signalgate:backend-sdk:0.1.0'
PHPcomposer require signalgate/signalgate-php
Java · Maven
<dependency>
  <groupId>ai.signalgate</groupId>
  <artifactId>backend-sdk</artifactId>
  <version>0.1.0</version>
</dependency>
Plain HTTP · smoke-test the key
# 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