Backend SDK
Server-side client that turns the frontend payload + request context into a logged event during Phase 2, then into a verdict once your workflow is live. Five stacks: Python (default), Node.js, Go, Java — or the plain HTTP API from any language.
Install
Python 3.10+. Published on PyPI as signalgate.
pip install signalgateThe only runtime dependency is requests (2.28+).
Client setup
One Client per process. Thread-safe; reuse the same instance across all your action handlers.
from signalgate import Client
# api_key is positional; every other option is keyword-only (defaults shown)
client = Client(
"pk_live_...",
check_timeout_ms=3000,
log_timeout_ms=1000,
log_queue_capacity=10000,
log_max_retries=3,
log_retry_base_ms=200,
fail_open=True,
)Constructor options
| Option | Type | Default | Purpose |
|---|---|---|---|
| api_key | str | required | Your pk_live_ key. Sent as a Bearer token. |
| check_timeout_ms | int | 3000 | Hard ceiling for a check() call. |
| log_timeout_ms | int | 1000 | Per-attempt timeout for the async log worker. |
| log_queue_capacity | int | 10000 | In-memory queue size for async log delivery. |
| log_max_retries | int | 3 | Retries for failed log deliveries (up to 4 attempts total). |
| log_retry_base_ms | int | 200 | Base delay for the log retry backoff (200 → 400 → 800 ms). |
| fail_open | bool | True | On timeout, network failure or 5xx, check() returns a synthesized allow instead of raising. |
Shutdown
close() drains the log queue (within 5× log_timeout_ms) so in-flight events aren't lost. Call it at process shutdown — or use the client as a context manager.
from signalgate import Client
client = Client("pk_live_...")
# At process shutdown: drains the log queue (within 5 x log_timeout_ms)
client.close()
# Or scope the client to a block:
with Client(api_key="pk_live_...") as client:
...Event shape
What you pass to log() and check(). Build the payload from the browser envelope verbatim: EncryptedPayload(**payload_dict).
from signalgate import Event, EncryptedPayload
event = Event(
user_id="u_123", # your user ID
ip="203.0.113.42", # from X-Forwarded-For or request.remote_addr
method="login", # action name: "login", "deposit", ...
timestamp="2026-04-01T13:08:50+00:00", # ISO 8601 string
payload=EncryptedPayload(
encrypted="<base64 blob from the frontend SDK>",
timestamp=1748102400000, # int, unix milliseconds
nonce="aZ19bCde3fGhI4jK",
v=2, # the frontend SDK emits v=2
),
custom={"plan": "pro"}, # optional labeling fields
)
# Pass the browser envelope through verbatim:
payload_dict = {"encrypted": "<base64 blob>", "timestamp": 1748102400000,
"nonce": "aZ19bCde3fGhI4jK", "v": 2}
payload = EncryptedPayload(**payload_dict)Methods
checkSynchronous; returns a CheckResult with the verdict. No retries; bounded by check_timeout_ms. Call it at the top of the gated action handler — before the protected action — and gate on action == "block". With fail_open on (the default), degraded-service conditions return a synthesized allow with failed_open=True.
logFire-and-forget: enqueues the event and returns immediately; never raises. Bounded in-memory queue (10,000), delivered by a daemon thread with up to 4 attempts (200/400/800 ms backoff); a 4xx is dropped immediately. Call it after the action succeeds — and again at the target action, after it succeeds. Delivery failures are visible via client.metrics.
Examples
Phase 2 — log only
Two handlers: the action handler logs after a successful authenticate(); the target-action handler (the downstream conversion — here a deposit) builds its own event from its own request's fingerprint payload and logs after its own success. Never reuse one browser payload across two funnel points.
from datetime import datetime, timezone
from flask import Flask, request
from signalgate import Client, Event, EncryptedPayload
app = Flask(__name__)
client = Client(api_key="pk_live_...")
def sg_event(body: dict, method: str, custom: dict | None = None) -> Event:
"""Build an Event from THIS request's own fingerprint payload."""
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["signalgate"]),
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
def apply_deposit(email: str, amount: float) -> bool:
... # your business logic; True on success
# ── Action handler: the moment you protect ──
@app.post("/api/login")
def login():
body = request.get_json()
user = authenticate(body["email"], body["password"])
if not user:
return {"error": "invalid credentials"}, 401
# Log AFTER the action succeeded
client.log(sg_event(body, method="login"))
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}Once your workflow is live — check + log
Once your workflow is live, add check() at the top of the gated action handler — before the protected action — and gate on "block". The gated request now carries two sealed envelopes from the browser SDK — "signalgate" for the check and "signalgate_log" for the post-success log — because each API call consumes its payload's single-use nonce, so the checked payload can never be reused for the log. The target-action handler is unchanged.
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)}
# The target-action handler is unchanged from Phase 2.Verdict shape
The CheckResult dataclass returned by check().
from dataclasses import dataclass
@dataclass
class CheckResult:
action: str # "allow" | "admin_alert" | "dry_run_block" | "block" — open set
score: float # 0.0 | 0.25 | 0.5 | 1.0 (server-fixed per action)
request_id: str # UUID, useful for tracing in your logs
tenant_id: str
timestamp: str # ISO 8601
processing_time_us: int # server-side processing time
failed_open: bool # True only for a synthesized fail-open allowErrors
All exceptions inherit from SignalGateError. Catch the specific types your code handles. signalgate.TimeoutError and signalgate.NetworkError shadow Python built-ins — import them from signalgate.
from signalgate import (
Client,
Event,
EncryptedPayload,
SignalGateError, # base class
ConfigError, # invalid API key shape / constructor argument
TimeoutError, # exceeded check_timeout_ms (shadows the builtin!)
NetworkError, # DNS, TCP or TLS failure
ServerError, # non-2xx API response
)
client = Client(api_key="pk_live_...", fail_open=False)
event = Event(
user_id="u_123",
ip="203.0.113.42",
method="login",
timestamp="2026-04-01T13:08:50+00:00",
payload=EncryptedPayload(
encrypted="<base64 blob from the frontend SDK>",
timestamp=1748102400000,
nonce="aZ19bCde3fGhI4jK",
v=2,
),
)
try:
result = client.check(event)
except ServerError as e:
# 4xx always raises, even with fail_open=True — a bug in the request
print(e.status_code, e.code, e.message, e.request_id)
except (TimeoutError, NetworkError) as e:
# only reachable with fail_open=False; the default returns allow instead
print("degraded:", e)Install
Node.js 18+. Published on npm as @signalgate/node; TypeScript types ship with the package.
npm install @signalgate/nodeZero runtime dependencies. Works from both ESM and CommonJS.
Client setup
One Client per process; reuse the same instance across all your action handlers. Client is a named export — there is no default export.
import { Client } from "@signalgate/node";
// Single options object; keys are camelCase (defaults shown)
const client = new Client({
apiKey: "pk_live_...",
checkTimeoutMs: 3000,
logTimeoutMs: 1000,
logQueueCapacity: 10000,
logMaxRetries: 3,
logRetryBaseMs: 200,
failOpen: true,
});Constructor options
| Option | Type | Default | Purpose |
|---|---|---|---|
| apiKey | string | required | Your pk_live_ key. Sent as a Bearer token. |
| checkTimeoutMs | number | 3000 | Hard ceiling for a check() call. |
| logTimeoutMs | number | 1000 | Per-attempt timeout for async log delivery. |
| logQueueCapacity | number | 10000 | In-memory queue size for async log delivery. |
| logMaxRetries | number | 3 | Retries for failed log deliveries (up to 4 attempts total). |
| logRetryBaseMs | number | 200 | Base delay for the log retry backoff (200 → 400 → 800 ms). |
| failOpen | boolean | true | On timeout, network failure or 5xx, check() resolves to a synthesized allow instead of throwing. |
Shutdown
await client.close() is required at process shutdown — without it, queued log events are lost. It drains the queue within 5× logTimeoutMs. The optional timeout argument (timeoutS) is in seconds, unlike the millisecond constructor options.
import { Client } from "@signalgate/node";
const client = new Client({ apiKey: "pk_live_..." });
// ... your app runs and logs events ...
// At process shutdown close() is REQUIRED — it drains the log queue
// (within 5 x logTimeoutMs) so queued events aren't lost. Call it exactly
// once. The optional argument is in SECONDS (not ms): close(10) waits
// up to 10 s.
process.on("SIGTERM", async () => {
await client.close();
process.exit(0);
});Event shape
What you pass to check() and log(): a plain object with camelCase fields — the SDK maps them to the snake_case wire format. Pass the browser envelope through verbatim as payload.
const event = {
userId: "u_123", // your user ID
ip: "203.0.113.42", // from X-Forwarded-For or req.socket.remoteAddress
method: "login", // action name: "login", "deposit", ...
timestamp: "2026-04-01T13:08:50+00:00", // ISO 8601 string
payload: { // the browser envelope, verbatim
encrypted: "<base64 blob from the frontend SDK>",
timestamp: 1748102400000, // number, unix milliseconds
nonce: "aZ19bCde3fGhI4jK",
v: 2, // the frontend SDK emits v=2
},
custom: { plan: "pro" }, // optional labeling fields
};Methods
checkAsync — await client.check(event) resolves to a CheckResult with the verdict. No retries; bounded by checkTimeoutMs. Call it at the top of the gated action handler — before the protected action — and gate on action === "block". With failOpen on (the default), degraded-service conditions resolve to a synthesized allow with failedOpen: true.
logFire-and-forget: client.log(event) returns synchronously — do not await it — and never throws. Bounded in-memory queue (10,000), delivered asynchronously with up to 4 attempts (200/400/800 ms backoff); a 4xx is dropped immediately. Call it after the action succeeds — and again at the target action, after it succeeds.
Examples
Phase 2 — log only
Two handlers: the action handler logs after a successful authenticate(); the target-action handler (the downstream conversion — here a deposit) builds its own event from its own request's fingerprint payload and logs after its own success. Never reuse one browser payload across two funnel points.
import 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 THIS request's own fingerprint payload. */
function sgEvent(req, method, custom) {
return {
userId: req.body.email,
ip: req.headers["x-forwarded-for"] ?? req.socket.remoteAddress,
method,
timestamp: new Date().toISOString(),
payload: req.body.signalgate, // 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
}
async function applyDeposit(email, amount) {
// your business logic; true on success
}
// ── Action handler: the moment you protect ──
app.post("/api/login", async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "invalid credentials" });
// Log AFTER the action succeeded — sync call, do not await
client.log(sgEvent(req, "login"));
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", { target_action: true }));
res.json({ ok: true });
});
app.listen(3000);Once your workflow is live — check + log
Once your workflow is live, add check() at the top of the gated action handler — before the protected action — and gate on "block". The gated request now carries two sealed envelopes from the browser SDK — "signalgate" for the check and "signalgate_log" for the post-success log — because each API call consumes its payload's single-use nonce, so the checked payload can never be reused for the log. The target-action handler is unchanged.
import 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) });
});
app.listen(3000);
// The target-action handler is unchanged from Phase 2.Verdict shape
The CheckResult object check() resolves to. The action set is open — unknown actions are returned verbatim, so gate on "block" rather than enumerating.
interface CheckResult {
action: string; // "allow" | "admin_alert" | "dry_run_block" | "block" — open set
score: number; // 0.0 | 0.25 | 0.5 | 1.0 (server-fixed per action)
requestId: string; // UUID, useful for tracing in your logs
tenantId: string;
timestamp: string; // ISO 8601
processingTimeUs: number; // server-side processing time
failedOpen: boolean; // true only for a synthesized fail-open allow
}Errors
All errors extend SignalGateError. Narrow with instanceof and handle the specific types your code cares about.
import {
Client,
SignalGateError, // base class
ConfigError, // invalid API key shape / constructor option
TimeoutError, // exceeded checkTimeoutMs
NetworkError, // DNS, TCP or TLS failure
ServerError, // non-2xx API response
} from "@signalgate/node";
const client = new Client({ apiKey: "pk_live_...", failOpen: false });
const event = {
userId: "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,
},
};
try {
const result = await client.check(event);
console.log(result.action, result.score);
} catch (err) {
if (err instanceof ServerError) {
// 4xx always throws, even with failOpen: true — a bug in the request.
// err.message is the pre-rendered "[status] code: message" summary;
// statusCode / code / requestId are also available as discrete fields.
console.error(err.message, err.requestId);
} else if (err instanceof TimeoutError || err instanceof NetworkError) {
// only reachable with failOpen: false; the default resolves to allow instead
console.error("degraded:", err);
}
}Install
Go 1.22+. Module github.com/SignalGate/signalgate-go — standard library only, no third-party dependencies.
go get github.com/SignalGate/signalgate-goThe module path ends in -go, but the package name is signalgate — import it as signalgate "github.com/SignalGate/signalgate-go".
Client setup
One *Client per process. Safe for concurrent use; reuse the same instance across all your handlers. New returns an error for invalid configuration — and starts a background goroutine, so Close is mandatory.
package main
import (
"log"
"time"
signalgate "github.com/SignalGate/signalgate-go"
)
func main() {
// apiKey is the first argument; everything else is a functional option
// (defaults shown).
client, err := signalgate.New("pk_live_...",
signalgate.WithCheckTimeoutMS(3000),
signalgate.WithLogTimeoutMS(1000),
signalgate.WithLogQueueCapacity(10000),
signalgate.WithLogMaxRetries(3),
signalgate.WithLogRetryBaseMS(200),
signalgate.WithFailOpen(true),
)
if err != nil {
log.Fatal(err)
}
// New starts a background goroutine — Close is mandatory.
defer client.Close(5 * time.Second)
}Constructor options
| Option | Type | Default | Purpose |
|---|---|---|---|
| apiKey | string | required | Your pk_live_ key — the first argument to New. Sent as a Bearer token. |
| WithCheckTimeoutMS | int | 3000 | Hard ceiling for a Check call. |
| WithLogTimeoutMS | int | 1000 | Per-attempt timeout for the async log worker. |
| WithLogQueueCapacity | int | 10000 | In-memory queue size for async log delivery. |
| WithLogMaxRetries | int | 3 | Retries for failed log deliveries (up to 4 attempts total). |
| WithLogRetryBaseMS | int | 200 | Base delay for the log retry backoff (200 → 400 → 800 ms). |
| WithFailOpen | bool | true | On timeout, network failure or 5xx, Check returns a synthesized allow with a nil error instead of failing. |
Shutdown
New starts a background goroutine, so Close is mandatory: it drains the log queue so in-flight events aren't lost. Close(0) is not "don't wait" — it falls back to the default drain window of 5× the log timeout.
package main
import (
"log"
"time"
signalgate "github.com/SignalGate/signalgate-go"
)
func main() {
client, err := signalgate.New("pk_live_...")
if err != nil {
log.Fatal(err)
}
// At process shutdown: drains the log queue so in-flight events
// aren't lost.
defer client.Close(5 * time.Second)
// Close(0) is NOT "don't wait" — it falls back to the default drain
// window of 5 x the log timeout.
}Event shape
What you pass to Log and Check. Build the Payload from the browser envelope verbatim — field for field.
package main
import (
signalgate "github.com/SignalGate/signalgate-go"
)
func main() {
v := 2 // the frontend SDK emits v=2; V is *int — nil is omitted from the wire
event := signalgate.Event{
UserID: "u_123", // your user ID
IP: "203.0.113.42", // from X-Forwarded-For or r.RemoteAddr
Method: "login", // action name: "login", "deposit", ...
Timestamp: "2026-04-01T13:08:50+00:00", // ISO 8601 string
Payload: signalgate.EncryptedPayload{
Encrypted: "<base64 blob from the frontend SDK>",
Timestamp: 1748102400000, // int64, unix milliseconds
Nonce: "aZ19bCde3fGhI4jK",
V: &v,
},
Custom: map[string]any{"plan": "pro"}, // optional labeling fields; nil = omitted
}
_ = event // pass it to client.Check(...) / client.Log(...)
}Methods
checkSynchronous; returns (CheckResult, error). No retries; bounded by WithCheckTimeoutMS — there is no context.Context parameter, timeouts are configured on the client. Call it at the top of the gated action handler — before the protected action — and gate on Action == "block". With fail-open on (the default), degraded-service conditions return a synthesized allow with FailedOpen=true and a nil error; a 4xx always returns *ServerError.
logFire-and-forget: enqueues the event and returns immediately — no return value at all; never blocks. Bounded in-memory queue (10,000), delivered by a background goroutine with up to 4 attempts (200/400/800 ms backoff); a 4xx is dropped immediately. Call it after the action succeeds — and again at the target action, after it succeeds. Delivery failures are visible via client.Metrics().
Examples
Phase 2 — log only
Two handlers: the action handler logs after a successful authenticate(); the target-action handler (the downstream conversion — here a deposit) builds its own event from its own request's fingerprint payload and logs after its own success. Never reuse one browser payload across two funnel points.
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"`
SignalGate sgPayload `json:"signalgate"`
}
type depositRequest struct {
Email string `json:"email"`
Amount float64 `json:"amount"`
SignalGate sgPayload `json:"signalgate"`
}
// sgEvent builds an Event from THIS request's own fingerprint payload.
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 applyDeposit(email string, amount float64) bool {
return true // your business logic; true on success
}
// ── Action handler: the moment you protect ──
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
}
if !authenticate(req.Email, req.Password) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
// Log AFTER the action succeeded
client.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 ──
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})
}
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))
}Once your workflow is live — check + log
Once your workflow is live, add Check at the top of the gated action handler — before the protected action — and gate on "block". The gated request now carries two sealed envelopes from the browser SDK — "signalgate" for the Check and "signalgate_log" for the post-success Log — because each API call consumes its payload's single-use nonce, so the checked payload can never be reused for the log. The target-action handler is unchanged.
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),
})
}
func main() {
var err error
client, err = signalgate.New("pk_live_...")
if err != nil {
log.Fatal(err)
}
defer client.Close(5 * time.Second)
http.HandleFunc("POST /api/login", handleLogin)
log.Fatal(http.ListenAndServe(":8080", nil))
}
// The target-action handler is unchanged from Phase 2.Verdict shape
The CheckResult struct returned by Check.
// The CheckResult struct returned by client.Check.
type CheckResult struct {
Action string // "allow" | "admin_alert" | "dry_run_block" | "block" — open set
Score float64 // 0.0 | 0.25 | 0.5 | 1.0 (server-fixed per action)
RequestID string // UUID, useful for tracing in your logs
TenantID string
Timestamp string // ISO 8601
ProcessingTimeUS int64 // server-side processing time
FailedOpen bool // true only for a synthesized fail-open allow
}Errors
The error set is sealed: *ConfigError, *TimeoutError, *NetworkError and *ServerError. Match with errors.As. With fail-open on (the default), Check only returns *ServerError (4xx) in practice — timeouts and network failures synthesize an allow instead.
package main
import (
"errors"
"fmt"
"log"
"time"
signalgate "github.com/SignalGate/signalgate-go"
)
func main() {
client, err := signalgate.New("pk_live_...", signalgate.WithFailOpen(false))
if err != nil {
log.Fatal(err) // *ConfigError: invalid API key shape / bad option
}
defer client.Close(5 * time.Second)
v := 2
event := signalgate.Event{
UserID: "u_123",
IP: "203.0.113.42",
Method: "login",
Timestamp: "2026-04-01T13:08:50+00:00",
Payload: signalgate.EncryptedPayload{
Encrypted: "<base64 blob from the frontend SDK>",
Timestamp: 1748102400000,
Nonce: "aZ19bCde3fGhI4jK",
V: &v,
},
}
result, err := client.Check(event)
if err != nil {
var se *signalgate.ServerError
var te *signalgate.TimeoutError
var ne *signalgate.NetworkError
switch {
case errors.As(err, &se):
// 4xx always returns *ServerError, even with fail-open on —
// a bug in the request
fmt.Println(se.StatusCode, se.Code, se.Message, se.RequestID)
case errors.As(err, &te), errors.As(err, &ne):
// only reachable with WithFailOpen(false); the default
// synthesizes an allow instead
fmt.Println("degraded:", err)
}
return
}
fmt.Println(result.Action, result.Score)
}Install
Java 17+. Published on Maven Central as ai.signalgate:backend-sdk.
<dependency>
<groupId>ai.signalgate</groupId>
<artifactId>backend-sdk</artifactId>
<version>0.1.0</version>
</dependency>The only runtime dependency is jackson-databind.
Client setup
Builder-only construction. Build one Client per process and reuse it across all your action handlers; Client implements AutoCloseable.
import ai.signalgate.sdk.*;
public class SignalGateHolder {
// One Client per process — build it once and reuse it across handlers.
// Builder-only construction; defaults shown.
public static final Client CLIENT = Client.builder("pk_live_...")
.checkTimeoutMs(3000)
.logTimeoutMs(1000)
.logQueueCapacity(10000)
.logMaxRetries(3)
.logRetryBaseMs(200)
.failOpen(true)
.build();
}Constructor options
| Option | Type | Default | Purpose |
|---|---|---|---|
| apiKey | String | required | Your pk_live_ key, passed to Client.builder(). Sent as a Bearer token. |
| checkTimeoutMs | int | 3000 | Hard ceiling for a check() call. |
| logTimeoutMs | int | 1000 | Per-attempt timeout for async log delivery. |
| logQueueCapacity | int | 10000 | In-memory queue size for async log delivery. |
| logMaxRetries | int | 3 | Retries for failed log deliveries (up to 4 attempts total). |
| logRetryBaseMs | int | 200 | Base delay for the log retry backoff (200 → 400 → 800 ms). |
| failOpen | boolean | true | On timeout, network failure or 5xx, check() returns a synthesized allow instead of throwing. |
| logger | Logger | — | Optional SDK diagnostics: pass your own Logger implementation (a NoopLogger ships with the SDK). |
Shutdown
close() drains the queued log events so in-flight logs aren't lost. Client implements AutoCloseable — scope it with try-with-resources, or call close() at process shutdown (optionally with a drain budget in seconds).
import ai.signalgate.sdk.*;
public class Shutdown {
public static void main(String[] args) {
// Client implements AutoCloseable — try-with-resources drains the log queue:
try (Client client = Client.builder("pk_live_...").build()) {
// ... client.check(...) / client.log(...) ...
}
// Or close explicitly at process shutdown:
Client longLived = Client.builder("pk_live_...").build();
longLived.close(); // drains queued log events
// longLived.close(2.5); // overload: drain budget in seconds
}
}Event shape
What you pass to check() and log(). Map the browser envelope fields into EncryptedPayload verbatim — the values must come through unchanged.
import ai.signalgate.sdk.*;
import java.util.Map;
public class BuildEvent {
public static void main(String[] args) {
// The browser envelope, passed through verbatim
EncryptedPayload payload = new EncryptedPayload(
"<base64 blob from the frontend SDK>", // encrypted
1748102400000L, // long, unix milliseconds
"aZ19bCde3fGhI4jK", // nonce
2); // Integer — the frontend SDK emits v=2
// 3-argument form omits v: new EncryptedPayload(encrypted, timestampMs, nonce)
Event event = new Event(
"u_123", // your user ID
"203.0.113.42", // from X-Forwarded-For or the socket
"login", // action name: "login", "deposit", ...
"2026-04-01T13:08:50+00:00", // ISO-8601 string
payload,
Map.of("plan", "pro")); // optional labeling fields — or null
System.out.println(event);
}
}Methods
checkBlocking; returns a CheckResult record — accessors carry no get- prefix (result.action(), not getAction()). Bounded by checkTimeoutMs. Call it at the top of the gated action handler — before the protected action — and gate on "block".equals(result.action()). With failOpen on (the default), degraded-service conditions return a synthesized allow with failedOpen() == true; a 4xx always throws ServerException.
logFire-and-forget: returns void and never throws. Events are queued in memory (logQueueCapacity) and delivered asynchronously with up to 4 attempts (200/400/800 ms backoff); a 4xx is dropped immediately. Call it after the action succeeds — and again at the target action, after its own success.
Examples
Phase 2 — log only
One Spring controller, two handlers: the action handler logs after a successful authenticate(); the target-action handler (the downstream conversion — here a deposit) builds its own Event from its own request's fingerprint payload and logs after its own success. Never reuse one browser payload across two funnel points.
import ai.signalgate.sdk.*;
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();
// The browser envelope, forwarded by your frontend verbatim
public record SgPayload(String encrypted, long timestamp, String nonce, Integer v) {}
public record LoginRequest(String email, String password, SgPayload signalgate) {}
public record DepositRequest(String email, double amount, SgPayload signalgate) {}
/** Build an Event from THIS request's own fingerprint payload. */
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
}
private boolean applyDeposit(String email, double amount) {
return true; // your business logic; true on success
}
// ── Action handler: the moment you protect ──
@PostMapping("/api/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest req,
HttpServletRequest http) {
Object user = authenticate(req.email(), req.password());
if (user == null) {
return ResponseEntity.status(401).body(Map.of("error", "invalid credentials"));
}
// Log AFTER the action succeeded
client.log(sgEvent(http, req.email(), "login", req.signalgate(), 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));
}
}Once your workflow is live — check + log
Once your workflow is live, add check() at the top of the gated action handler — before the protected action — and gate on "block". The gated request now carries two sealed envelopes from the browser SDK — "signalgate" for the check and "signalgate_log" for the post-success log — because each API call consumes its payload's single-use nonce, so the checked payload can never be reused for the log. The target-action handler is unchanged.
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)));
}
// The target-action handler is unchanged from Phase 2.
}Verdict shape
The CheckResult record returned by check(). Record accessors carry no get- prefix.
// The CheckResult record returned by check() — accessors without a get- prefix:
public record CheckResult(
String action, // "allow" | "admin_alert" | "dry_run_block" | "block" — open set
double score, // 0.0 | 0.25 | 0.5 | 1.0 (server-fixed per action)
String requestId, // UUID, useful for tracing in your logs
String tenantId,
String timestamp, // ISO 8601
long processingTimeUs, // server-side processing time
boolean failedOpen // true only for a synthesized fail-open allow
) {}Errors
Every exception is unchecked and extends SignalGateException: ServerException, ConfigException, SignalGateTimeoutException and NetworkException. Catch the specific types your code handles.
import ai.signalgate.sdk.*;
public class HandleErrors {
public static void main(String[] args) {
try (Client client = Client.builder("pk_live_...").failOpen(false).build()) {
EncryptedPayload payload = new EncryptedPayload(
"<base64 blob from the frontend SDK>",
1748102400000L,
"aZ19bCde3fGhI4jK",
2);
Event event = new Event("u_123", "203.0.113.42", "login",
"2026-04-01T13:08:50+00:00", payload, null);
try {
CheckResult result = client.check(event);
System.out.println(result.action());
} catch (ServerException e) {
// 4xx always throws, even with failOpen(true) — a bug in the request
System.err.println(e.statusCode() + " " + e.code() + " "
+ e.message() + " " + e.requestId());
} catch (SignalGateTimeoutException | NetworkException e) {
// only reachable with failOpen(false); the default returns allow instead
System.err.println("degraded: " + e.getMessage());
}
}
}
}Install
No SDK required — curl, your language's standard library, or any HTTP client you already use.
# 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 bodyThe API is versioned in the URL path (/v0); there is no client version to pin.
Client setup
Base URL and auth headers — identical for both endpoints.
https://api.signalgate.ai
Authorization: Bearer pk_live_...
Content-Type: application/jsonShutdown
There is no client to shut down — each call is a single HTTPS request. For /v0/log, retry up to 3 times with 200/400/800 ms backoff on timeout, network failure or 5xx; drop the event on any 4xx. A log failure must never break your action.
Event shape
The JSON request body — identical shape for /v0/check and /v0/log.
{
"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
},
"custom": { "plan": "pro" }
}Methods
checkPOST /v0/check → 200 OK with the verdict envelope. Call it before the protected action and gate on data.action == "block". Never retry a check — on timeout or 5xx synthesize {"action": "allow", "score": 0} locally.
logPOST /v0/log → 200 OK with an empty envelope ({"ok": true, "data": null, "error": null}). Call it after the action succeeds, and at the target action after it succeeds. Retry up to 3 times with 200/400/800 ms backoff on timeout/network/5xx; drop on any 4xx.
Examples
Phase 2 — log only
Two funnel points, each with its own freshly-collected browser payload: log the action after it succeeds, and log the target action after it succeeds.
# ── Action place: log AFTER a successful login ──
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:50+00:00",
"payload": {
"encrypted": "<base64 blob from the frontend SDK>",
"timestamp": 1748102400000,
"nonce": "aZ19bCde3fGhI4jK",
"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 }
}'Once your workflow is live — check + log
Once your workflow is live: check before the action, gate on "block", run the action, then log after it succeeds — with a second, freshly-captured payload, because each API call consumes its payload's single-use nonce and the checked payload can never be reused for the log. The jq line extracts the gate value.
# 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" | "admin_alert" | "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
}
}'Verdict shape
A successful /v0/check response — the verdict lives in data.
200 OK
{
"ok": true,
"data": {
"action": "allow",
"score": 0.0,
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"tenant_id": "acme",
"timestamp": "2026-04-01T13:08:50Z",
"processing_time_us": 45
},
"error": null
}Errors
Every non-2xx response carries the same error envelope; details is omitted in production.
401 Unauthorized
{
"ok": false,
"data": null,
"error": {
"code": "UNAUTHORIZED",
"message": "invalid or revoked API key",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}Where each call goes
The same contract in every stack: two handlers, each with its own event.
check() — before the protected action
Call check() at the top of the gated action handler, before the action itself, and gate on action == "block".
log() — after the action succeeds
Log the action-place event only after the action itself succeeded — not before, and not on failure.
Target-action log() — after the target action succeeds
The downstream conversion is its own funnel point: its handler builds its own event from its own request and logs after its own success. A distinct method value at the target point is the primary linkage between the two.
A fresh payload for every funnel point
Never reuse a browser envelope across funnel points — the target-action log must carry its own freshly-collected payload. A reused nonce is rejected as a replay.
Verdicts
The four actions check can return, and how to handle each. Treat the set as open: new values may appear over time — match on the ones you handle and fall through safely for anything unknown.
allowscore 0.0No risk detected. Let the request through. The vast majority of legitimate traffic falls here.
Recommended handling: Continue normally.
admin_alertscore 0.25The request is allowed, but flagged for review — it surfaces in the dashboard events log for your team to inspect.
Recommended handling: Allow; review the flagged activity in the dashboard.
dry_run_blockscore 0.5A would-be-block in shadow mode — the request is allowed, so you can see what enforcement would catch before turning it on.
Recommended handling: Allow, but treat as a future block.
blockscore 1.0High-confidence fraud. Reject the action.
Recommended handling: Refuse the action; surface a generic error to the user.
HTTP API reference
The raw REST surface every SDK wraps — and the direct integration path for any other language. Both endpoints share one request-body shape and one response envelope.
Base URL & auth
https://api.signalgate.ai
Authorization: Bearer pk_live_...
Content-Type: application/jsonOptional headers: X-Request-Id (fresh per attempt, for tracing) and Idempotency-Key (stable across retries of the same event; accepted, but not yet processed server-side).
POST /v0/check (once your workflow is live)
Synchronous verdict. Returns 200 OK with the verdict in data. Call it before the protected action and gate on data.action == "block".
POST /v0/check
{
"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
},
"custom": { "plan": "pro" }
}POST /v0/log (Phase 2 — and ongoing after launch)
Fire-and-forget event logging. The request body is identical to /v0/check. Returns 200 OK with an empty envelope — there is no 202.
200 OK
{
"ok": true,
"data": null,
"error": null
}Error envelope
Every non-2xx response uses the same envelope. details is omitted in production.
401 Unauthorized
{
"ok": false,
"data": null,
"error": {
"code": "UNAUTHORIZED",
"message": "invalid or revoked API key",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}| Code | Status | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing, invalid, or revoked API key. |
| BAD_REQUEST | 400 | Malformed body or missing required field. |
| INVALID_PAYLOAD | 400 | The fingerprint payload could not be read — corrupt or truncated envelope. |
| REQUEST_REJECTED | 422 | Stale or replayed payload (e.g. a reused nonce). |
Status codes
| Status | Meaning |
|---|---|
| 200 OK | Success — check returns the verdict in data; log returns an empty envelope. |
| 400 Bad Request | Malformed body (BAD_REQUEST) or corrupt fingerprint payload (INVALID_PAYLOAD). |
| 401 Unauthorized | Missing, invalid, or revoked API key (UNAUTHORIZED). |
| 422 Unprocessable Entity | Stale or replayed payload (REQUEST_REJECTED). |
| 5xx | Service degraded — apply the fail-open guidance below. |
Fail-open guidance
check — on timeout, network failure or 5xx, synthesize {"action": "allow", "score": 0} locally and let the request through. Never retry a check. A 4xx is a bug in your request — surface it loudly.
log — retry up to 3 times with 200/400/800 ms backoff on timeout, network failure or 5xx; drop the event on any 4xx. A log failure must never break your action.
Continue reading