API Reference
Complete reference for Sentry, TrapBuilder, and WatchBuilder.
This reference documents the core classes and methods available in the trappsec SDK.
Sentry
The main entry point for the library.
Sentry(app, service: str, environment: str)
new Sentry(app, service, environment)
InstallSentry(router, service, environment string) *App
Import path varies by framework:
github.com/trappsec-dev/trappsec/packages/go/gingithub.com/trappsec-dev/trappsec/packages/go/nethttpgithub.com/trappsec-dev/trappsec/packages/go/echo(Echo v5)github.com/trappsec-dev/trappsec/packages/go/handler(chi, gorilla/mux or any otherhttp.Handler)
The handler adapter wraps the router and answers traps before it. Tell it how to ask the router which route a request matches, so that a real route at least as specific as a trap (POST /users/me next to a trap POST /users/{id}) wins, a trap that duplicates a route panics at bootstrap, and watches apply only to the route they name:
r := chi.NewRouter()
// ... real routes ...
app := trappsechandler.InstallSentry(r, "svc", "prod")
app.MatchRoutes(func(req *http.Request) string {
return r.Find(chi.NewRouteContext(), req.Method, req.URL.Path)
})
http.ListenAndServe(":8080", app)
For gorilla/mux, return the template of the matched route:
app.MatchRoutes(func(req *http.Request) string {
var m mux.RouteMatch
if !r.Match(req, &m) || m.MatchErr != nil || m.Route == nil {
return ""
}
tpl, _ := m.Route.GetPathTemplate()
return tpl
})
Without MatchRoutes, traps and watches match the request path alone, like Sentry.IsTrap. With it, a trap does not answer its path with a trailing slash, since chi and gorilla/mux answer those with 404. See examples/go-chi for a complete app.
Every adapter also has Use(s *trappsec.Sentry, router) *App to wire a Sentry you created with NewSentry from the core package. Each adapter package re-exports the core types and the NoDefault, Default, Intent and Metadata helpers, so one import is enough.
Initializes the trappsec Sentry.
- app / router: The application or router instance (Flask/FastAPI/Express/NestJS/Hono/gin.Engine/http.ServeMux/*echo.Echo/any http.Handler).
- service: Name of your service (e.g., “PaymentService”).
- environment: Deployment environment (e.g., “Production”, “Staging”).
TrapBuilder
Returned by ts.trap(path). Used to configure a decoy route.
methods
trap.methods("GET", "POST")
trap.methods("GET", "POST");
trap.Methods("GET", "POST")
Specifies the HTTP methods this trap should accept. Defaults to ["GET", "POST"].
intent
trap.intent("Credential Harvesting")
trap.intent("Credential Harvesting");
trap.Intent("Credential Harvesting")
Sets the intent label for alerts generated by this trap.
respond
Configures the response sent to authenticated requests. Unauthenticated requests will receive a default 401 Unauthorized response (which you can override globally or per-trap using if_unauthenticated).
Signature
trap.respond(
status: int,
body: dict | str | Callable,
mime_type: str = None,
template: str = None,
)
trap.respond({
status: number,
body: object | string | Function,
mime_type: string,
template: string,
})
trap.Respond(trappsec.ResponseConfig{
Status: int, Body: any,
MIMEType: string, Template: string,
})
Examples
trap.respond(200, {"status": "ok"})
trap.respond(403, "Access Denied", mime_type="text/plain")
trap.respond(template="deprecated_api")
trap.respond({ status: 200, body: { "status": "ok" } });
trap.respond({ status: 403, body: "Access Denied", mime_type: "text/plain" });
trap.respond({ template: "deprecated_api" });
trap.Respond(trappsec.ResponseConfig{
Status: 200, Body: map[string]any{"status": "ok"},
})
trap.Respond(trappsec.ResponseConfig{
Status: 403, Body: "Access Denied", MIMEType: "text/plain",
})
trap.Respond(trappsec.ResponseConfig{Template: "deprecated_api"})
- status: HTTP status code.
- body: JSON object, string, or a function returning one of those.
- mime_type: Content-Type header (defaults to “application/json”). You must explicitly set this to “text/plain” (or others) if returning a non-JSON body.
- template: Name of a registered response template.
if_unauthenticated
Configures a specific response for unauthenticated requests. Overrides respond for unauthenticated traffic.
Signature
trap.if_unauthenticated(
status: int,
body: dict | Callable,
mime_type: str = None,
template: str = None,
)
trap.if_unauthenticated({
status: number,
body: object | Function,
mime_type: string,
template: string,
})
trap.IfUnauthenticated(trappsec.ResponseConfig{
Status: int, Body: any,
MIMEType: string, Template: string,
})
Example
trap.if_unauthenticated(401, {"error": "Login Required"})
trap.if_unauthenticated({ status: 401, body: { "error": "Login Required" } });
trap.IfUnauthenticated(trappsec.ResponseConfig{
Status: 401,
Body: map[string]any{"error": "Login Required"},
})
WatchBuilder
Returned by ts.watch(path). Used to configure honey fields on legitimate routes.
body
Monitors the request body for specific keys.
Signature
watch.body(name: str, default: Any = NO_DEFAULT, intent: str = None)
watch.body(name, { default: any, intent: string })
watch.Body(name string, opts ...any) // trappsec.Default(v), trappsec.Intent(s)
Example
watch.body("is_admin", default=False, intent="PrivEsc")
watch.body("is_admin", { default: false, intent: "PrivEsc" });
// Alert on any presence of 'is_admin'
watch.Body("is_admin", trappsec.Intent("PrivEsc"))
// Alert only if value differs from default
watch.Body("is_admin", trappsec.Default(false), trappsec.Intent("PrivEsc"))
- name: The field name to watch.
- default (Go:
trappsec.Default(v)): (Optional) If provided, alerts only if the value differs from this default. If omitted, alerts on any presence of the key. - intent (Go:
trappsec.Intent(s)): (Optional) The intent label for the alert.
The pre-0.3 spellings still work: Node’s { defaultValue }, and Go’s positional Body(name, defaultValue, intent) with trappsec.NoDefault for “no default”.
query
Monitors the URL query string for specific keys. Same semantics as body; events record found_fields[].type as "query".
watch.query(name: str, default: Any = NO_DEFAULT, intent: str = None)
watch.query("debug", default="false", intent="Recon")
watch.query(name, { default: any, intent: string })
watch.query("debug", { default: "false", intent: "Recon" });
watch.Query(name string, opts ...any)
watch.Query("debug", trappsec.Default("false"), trappsec.Intent("Recon"))
Matching semantics (identical for query and body):
- Values are compared with canonical matching:
1equals1.0,trueequals"true",5equals"5". Strings compare exactly. See Event Reference. - Multi-valued fields (
?debug=a&debug=b): a field sent once is compared as a scalar in every framework; with several values it is a violation unless every value equals the default. All occurrences are stripped before your handler runs. - Values nested more than 32 levels deep never match a default (so they always alert) and are recorded with the too-deep part replaced by
"<nested>". - Calling
watch(path)twice for the same path merges the fields; a later rule for the same field wins. - List and object defaults (
default=[],default={}) are compared element by element and key by key with the same rules, so a request that sends exactly the default is not an alert. - Every occurrence of a watched key is removed from the request your handler sees, so a honey field can never influence real logic.
- Go matches JSON keys case-insensitively (as
encoding/jsondoes); Python and Node are case-sensitive. - A JSON body that is an array is inspected element by element (object elements only). Other body shapes are skipped.
bodywatches need a parsed body. On Express and Koa, register a body parser (app- or route-level); Hono bodies are parsed by trappsec; see Production and Framework differences.
Configuration
Global configuration methods.
identify_user
Registers a callback to extract user identity from the request. It must return an object/dict with the keys user and role (or null/nil when unauthenticated). Any other key names (such as id or user_id) are ignored and no alert will fire. In Go, return *trappsec.AuthContext{User, Role}.
# Extract user info from your authentication middleware (e.g. Flask-Login, JWT)
ts.identify_user(lambda r: {
"user": getattr(r.user, "id", None),
"role": getattr(r.user, "role", "guest")
})
// Extract user info from your authentication middleware (e.g. Passport, Clerk)
ts.identify_user((req) => ({
"user": req.user?.id,
"role": req.user?.role || "guest"
}));
ts.IdentifyUser(func(req any) *trappsec.AuthContext {
c := req.(*gin.Context) // cast to your framework's request type
uid := c.GetHeader("x-user-id")
if uid == "" {
return nil // unauthenticated
}
return &trappsec.AuthContext{User: uid, Role: c.GetHeader("x-user-role")}
})
override_source_ip
Registers a callback to extract the real client IP (e.g., from X-Forwarded-For).
ts.override_source_ip(lambda r: r.remote_addr)
ts.override_source_ip((req) => req.ip);
ts.OverrideSourceIP(func(req any) string {
c := req.(*gin.Context)
if ip := c.GetHeader("x-real-ip"); ip != "" {
return ip
}
return c.ClientIP()
})
add_webhook
Adds a webhook destination for alerts.
ts.add_webhook("https://...")
ts.add_webhook("https://...", alerts_only=False)
ts.add_webhook("https://...");
ts.add_webhook("https://...", { alerts_only: false });
ts.AddWebhook("https://...", nil)
// Send all events (not just alerts)
alertsOnly := false
ts.AddWebhook("https://...", &trappsec.WebhookOptions{AlertsOnly: &alertsOnly})
By default, webhook integration is alerts_only=true, so only events with type="alert" are delivered.
Set alerts_only to false in add_webhook options to forward both alert and signal events.
Options
| Option | Description |
|---|---|
secret |
If set, every request (including heartbeats) is signed. See signing. |
headers |
Extra HTTP headers (e.g. Authorization). |
heartbeat_interval |
Seconds between trappsec.heartbeat events, so your receiver can detect a silent sensor. Omit to disable. |
template |
A function mapping the event to the payload you want to send. If it raises, the event is dropped for this handler. |
alerts_only |
Default true: signals are dropped. Set false to also forward signals. |
ts.add_webhook(
"https://siem.example.com/hooks/trappsec",
secret="s3cret",
headers={"Authorization": "Bearer ..."},
heartbeat_interval=300,
template=lambda e: {"text": f"{e['event']} {e['path']}"},
alerts_only=True,
)
ts.add_webhook("https://siem.example.com/hooks/trappsec", {
secret: "s3cret",
headers: { Authorization: "Bearer ..." },
heartbeat_interval: 300,
template: (e) => ({ text: `${e.event} ${e.path}` }),
alerts_only: true,
});
ts.AddWebhook("https://siem.example.com/hooks/trappsec", &trappsec.WebhookOptions{
Secret: "s3cret",
Headers: map[string]string{"Authorization": "Bearer ..."},
HeartbeatInterval: 300,
Template: func(e trappsec.TriggerContext) any { return map[string]any{"text": e.Event} },
})
Use add_webhook for generic HTTP receivers. For Slack incoming webhooks use add_slack, which sends a Slack-formatted payload.
add_slack
Adds a Slack-optimized output using an incoming webhook URL. Request-derived values are escaped (&, <, >) before being placed in the message, and a plain-text fallback is always set. Signals are dropped unless alerts_only is false.
ts.add_slack("https://hooks.slack.com/services/...")
ts.add_slack("https://hooks.slack.com/services/...", alerts_only=False)
ts.add_slack("https://hooks.slack.com/services/...");
ts.add_slack("https://hooks.slack.com/services/...", { alerts_only: false });
ts.AddSlack("https://hooks.slack.com/services/...", nil)
// Send all events (not just alerts)
alertsOnly := false
ts.AddSlack(
"https://hooks.slack.com/services/...",
&trappsec.SlackOptions{AlertsOnly: &alertsOnly},
)
add_otel
Enables OpenTelemetry integration for alerts.
ts.add_otel()
ts.add_otel();
ts.AddOTEL()
template
Registers a named, reusable response that traps can reference via respond(template=...) / if_unauthenticated(template=...) and that default_response(template=...) can use. A template cannot be combined with status, body or mime_type in the same call. The MIME type argument is optional everywhere (default application/json).
ts.template("deprecated_api", 410, {"error": "This API version is retired"})
ts.trap("/v1/users").respond(template="deprecated_api")
ts.template("deprecated_api", 410, { error: "This API version is retired" });
ts.trap("/v1/users").respond({ template: "deprecated_api" });
ts.Template("deprecated_api", 410, map[string]any{"error": "This API version is retired"})
ts.Trap("/v1/users").Respond(trappsec.ResponseConfig{Template: "deprecated_api"})
- String bodies are sent raw regardless of
mime_type; other values are JSON-encoded forapplication/json. responduses “was provided” checks, sobody={},"",0,falseand a status of0are honored rather than ignored.
Default responses
default_response and default_unauthenticated_response (Go: DefaultResponse / DefaultUnauthenticatedResponse) set the responses used when a trap does not configure respond / if_unauthenticated. They take exactly the same arguments as those methods: only what you pass changes the default, and template replaces it. Defaults are resolved when traps are built (at bootstrap), not when .trap() is called, so you may set them before or after declaring traps.
ts.default_unauthenticated_response(status=403, body={"error": "Access Denied"})
ts.default_response(template="deprecated_api")
ts.default_unauthenticated_response({ status: 403, body: { error: "Access Denied" } });
ts.default_response({ template: "deprecated_api" });
ts.DefaultUnauthenticatedResponse(trappsec.ResponseConfig{Status: 403, Body: map[string]any{"error": "Access Denied"}})
ts.DefaultResponse(trappsec.ResponseConfig{Template: "deprecated_api"})
The older forms still work: assigning ts.default_responses["unauthenticated"] = {"status_code": ..., "response_body": ..., "mime_type": ...} in Python and Node, and SetDefaultAuthenticated / SetDefaultUnauthenticated (deprecated) in Go.
trigger
Emits a trappsec.rule_hit event from your own business logic. Available in Python, Node and Go.
ts.trigger(request, "Velocity limit exceeded", intent="fraud_attempt",
metadata={"amount": 50000})
ts.trigger(req, "Velocity limit exceeded", { intent: "fraud_attempt", metadata: { amount: 50000 } });
ts.Trigger(c, "Velocity limit exceeded",
trappsec.Intent("fraud_attempt"), trappsec.Metadata(map[string]any{"amount": 50000}))
- req: The framework request object (the same type you receive in
identify_user). - reason: Free-text explanation.
- intent, metadata: Optional (Python keywords, Node options object, Go
trappsec.Intent/trappsec.Metadata);nullin the event when omitted. The positional formtrigger(req, reason, intent, metadata)still works in Node and Go.
trigger never raises into your handler; failures are logged.
is_trap / IsTrap
Returns whether a request targets a trap (method and path). Use it to let traps through middleware that would otherwise reject them before they can answer, typically CSRF protection: a POST trap that answers 403 is disabled and easy to fingerprint. Trap path segments written as :id, {id}, <id> or <int:id> match any single segment; a last segment written *rest, {rest...}, {rest:path} or <path:rest> matches one or more remaining segments. The rules are shared by all three SDKs (tests/conformance/is_trap.json). It never raises, and it is a cached lookup, cheap enough to run on every request.
# e.g. inside a CSRF middleware's dispatch
if request.method == "POST" and not ts.is_trap(request):
verify_csrf_token(request)
# starlette-csrf has no skip callback: route traps around it
from starlette.requests import Request
from starlette_csrf import CSRFMiddleware
class CSRFExceptTraps:
def __init__(self, app, **options):
self.app, self.csrf = app, CSRFMiddleware(app, **options)
async def __call__(self, scope, receive, send):
trap = scope["type"] == "http" and ts.is_trap(Request(scope))
await (self.app if trap else self.csrf)(scope, receive, send)
app.add_middleware(CSRFExceptTraps, secret="...")
Flask-WTF’s CSRFProtect, Django’s CSRF middleware, Litestar’s CSRFConfig and Tornado’s xsrf_cookies exempt traps automatically.
// Express (csurf, csrf-csrf's doubleCsrfProtection): register at app level, so req.path is the full path
app.use((req, res, next) => (ts.is_trap(req) ? next() : csrfProtection(req, res, next)));
// Fastify (@fastify/csrf-protection)
app.addHook('onRequest', (req, reply, done) => (ts.is_trap(req) ? done() : app.csrfProtection(req, reply, done)));
Every adapter’s App.IsTrap takes its framework’s request type: *http.Request (net/http and handler), *gin.Context (Gin), *echo.Context (Echo). It answers from the route the framework matched (net/http asks the mux, handler asks the MatchRoutes function), so a real route that only looks like a trap, such as POST /users/me next to a trap POST /users/{id}, is never reported as one; prefer it over app.Sentry.IsTrap(req any), which matches the path against trap patterns like Python’s and Node’s is_trap.
// gorilla/csrf (net/http)
next := protect(app)
http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if app.IsTrap(r) {
r = csrf.UnsafeSkipCheck(r)
}
next.ServeHTTP(w, r)
}))
// net/http's CrossOriginProtection
protected := http.NewCrossOriginProtection().Handler(app)
http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if app.IsTrap(r) {
app.ServeHTTP(w, r)
return
}
protected.ServeHTTP(w, r)
}))
// Gin: in your CSRF middleware
engine.Use(func(c *gin.Context) {
if app.IsTrap(c) {
c.Next()
return
}
csrfMiddleware(c)
})
// Echo: App.IsTrap fits middleware.CSRFConfig.Skipper
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{Skipper: app.IsTrap}))
bootstrap / Bootstrap
Installs traps and watches. It is public, idempotent, and safe to call repeatedly or concurrently. You normally do not need it: the startup hook (lifespan / listen / Run / Start) calls it, and the first request calls it lazily where the framework allows (test clients, http.Server{Handler: app}, supertest, fastify.inject). Call it explicitly after finishing your configuration if you use a custom server or want traps installed before the first request.
ts.bootstrap()
ts.bootstrap();
app := trappsecnethttp.InstallSentry(mux, "svc", "prod")
// ... configure traps ...
app.Bootstrap()
srv := &http.Server{Addr: ":8080", Handler: app} // *App implements http.Handler
srv.ListenAndServe()
*App implements http.Handler (net/http), so http.Server{Handler: app}, httptest.NewServer(app) and custom servers are protected. For Gin and Echo, app also implements ServeHTTP.
Routes registered before Sentry was installed are still watched where the framework allows it; otherwise a warning names the uncovered routes. A trap whose path and method exactly duplicate an existing application route raises an error at bootstrap instead of silently replacing either.
close / Close
Stops webhook heartbeats and flushes the asynchronous delivery queue (default timeout 5 s: Python close(timeout=5.0) and Node await close(5) in seconds, Go Close(ctx) bounded by the context). Call it on shutdown, otherwise queued events can be lost. Before 0.3, Node took milliseconds; values of 1000 or more are still read as milliseconds, with a warning.
import atexit
atexit.register(ts.close) # or call ts.close(timeout=5) in your shutdown hook
for (const sig of ["SIGINT", "SIGTERM"]) {
process.once(sig, async () => { await ts.close(); process.exit(0); });
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = ts.Close(ctx) // flush pending deliveries
Delivery model
Webhook and Slack delivery is asynchronous. Events go to a bounded in-memory queue (capacity 1000) drained by a background worker (Python: daemon thread; Go: goroutine; Node: un-awaited node:http requests capped at 100 in flight). When the queue is full the event is dropped and a warning is logged (at most once per 60 s). Failed deliveries are retried at most twice with short backoff, in the worker, never on the request path. Non-2xx responses are logged. A slow or down webhook can never delay your HTTP responses.
Callback safety
Every callback you provide (identify_user, override_source_ip, callable defaults, dynamic response bodies, webhook template) must be synchronous and runs in a try/catch. A callback that returns a promise or coroutine is treated as failed: trappsec logs once that it must be synchronous (and, in Node, catches the promise so a rejection can never crash the process). On failure trappsec logs the error and falls back: identity becomes “no user”, the IP falls back to the default extractor, a failing callable default is treated as “no default” (presence is a violation), a failing dynamic body becomes {}, and a failing webhook template drops that event for that handler (the raw event is never sent instead).
Go request body limit
The Go adapters read request bodies in order to inspect watch.Body fields. Bodies up to DefaultMaxBodyBytes (1 MiB) are buffered and inspected; larger bodies are passed through to your handler uninspected (query watches still apply). Adjust it with app.SetMaxBodyBytes(n) on the Gin/Echo/net/http App. Keep your own http.MaxBytesReader limits in place for the real application.
Avoid a padding bypass: a body larger than the limit is not inspected, so an attacker could pad a request past it to slip a honey field through. Make sure
SetMaxBodyBytesis at least as large as the maximum body your watched routes accept (for example, thehttp.MaxBytesReaderlimit you already apply). Then no oversize body can reach your handler.
Gin route groups: routes registered before
InstallSentry, including routes in groups created before it, are still watched, as long as requests are served throughapp(app.Run*, orappas thehttp.Handler).