shieldlabs

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 1, 2026 License: MIT Imports: 26 Imported by: 0

README

ShieldLabs Go SDK

Read ShieldLabs identification verdicts, verify webhooks and apply risk checks from your Go backend.

CI Go Reference Version

How it fits

A ShieldLabs integration always has three steps:

  1. Browser. The ShieldLabs agent runs an identification and gives your page a request ID. The browser never sees a Risk Score or a device ID.
  2. Your backend. It receives the request ID together with the protected action (signup, login, checkout) and reads the verdict for it with this SDK, from the History API or from a signed webhook.
  3. Decision. Your backend acts on the Risk Score, the risk band, the detection flags and the identifiers, for example how many accounts one device ID already has.

This module covers steps 2 and 3. For step 1, see the browser SDKs and the documentation. New to ShieldLabs? Start free.

Install

go get github.com/ShieldLabs-ai/shieldlabs-go

Go 1.23 or later. The module uses only the standard library.

Quick start

Read the identification for a request ID, then evaluate it before creating the account:

package main

import (
	"context"
	"log"
	"os"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	client, err := shieldlabs.NewClient(os.Getenv("SHIELDLABS_API_KEY")) // sec_your_private_key
	if err != nil {
		log.Fatal(err)
	}

	// requestID arrives from your page together with the signup form.
	requestID := "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53"

	// Waits for the verdict with a 10-second budget; nil means "not scored in time".
	ident, err := client.Identifications.Get(context.Background(), requestID, nil)
	if err != nil {
		log.Fatal(err)
	}

	verdict := shieldlabs.EvaluateIdentification(ident, shieldlabs.EvaluateOptions{
		IsReplay: func(requestID string) bool { return alreadyUsed(requestID) },
	})
	if !verdict.OK {
		log.Printf("signup refused: %s", verdict.Reason) // missing, replayed, stale, blocked_band, ...
		return
	}
	log.Printf("signup allowed: risk score %d (%s)", ident.RiskScore, verdict.Band)
}

// alreadyUsed records the request ID in your store and reports whether it was
// there before. One identification authorizes one protected action.
func alreadyUsed(requestID string) bool { return false }

Verify and parse webhook deliveries:

package main

import (
	"errors"
	"io"
	"log"
	"net/http"
	"os"

	"github.com/ShieldLabs-ai/shieldlabs-go/webhook"
)

func main() {
	http.HandleFunc("POST /webhooks/shieldlabs", handleWebhook)
	log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "cannot read the body", http.StatusBadRequest)
		return
	}
	event, err := webhook.ConstructEvent(body, r.Header.Get(webhook.SignatureHeader),
		os.Getenv("SHIELDLABS_WEBHOOK_SECRET")) // whsec_your_signing_secret
	if errors.Is(err, webhook.ErrSignature) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	if err != nil {
		http.Error(w, "invalid payload", http.StatusBadRequest)
		return
	}
	if e, ok := event.(*webhook.IdentificationScoredEvent); ok {
		// Keep this idempotent on the request ID: a later server release
		// retries failed deliveries with identical bytes.
		log.Printf("request %s scored %d (%s)", e.Data.RequestID, e.Data.RiskScore, e.Data.Band())
	}
	w.WriteHeader(http.StatusOK) // answer within one second
}

examples/nethttp is a complete net/http server with a guarded POST /signup and a POST /webhooks/shieldlabs receiver.

Guide

Credentials
Variable Credential Used by
SHIELDLABS_API_KEY Private API Key (sec_...), one per domain NewClient (History API)
SHIELDLABS_SECRET_KEY and SHIELDLABS_DOMAIN Secret Key and the registered domain NewManagementClient (Management API)
SHIELDLABS_WEBHOOK_SECRET Endpoint signing secret (whsec_...) webhook.VerifySignature, webhook.ConstructEvent
SHIELDLABS_API_BASE_URL, SHIELDLABS_MANAGEMENT_BASE_URL Optional origin overrides for development and tests pass them to WithBaseURL; an empty value keeps the default

Keys stay on your server. The SDK reads no environment variables itself: pass values explicitly.

Waiting for the verdict

Scoring is asynchronous. The History row for a request ID appears about 1 to 3 seconds after the browser call, and follow-up checks can refine it for up to about 10 seconds. Start the identification in the browser when the user begins the action (for example when the signup form opens), not when the form is submitted, so that the verdict is usually ready when your backend asks for it. Identifications.Get waits for the row:

ident, err := client.Identifications.Get(ctx, requestID, &shieldlabs.GetIdentificationOptions{
	Timeout: 5 * time.Second, // total budget of the wait, default 10 s
})
  • Total budget. Timeout (default 10 seconds) bounds the whole wait. Timeout: 0 means the default 10 seconds, not a zero budget, so a nil options pointer and the zero value both wait with the defaults. NoWait: true makes a single lookup instead, retried like any History request.
  • Schedule. It looks up the request ID at once, then again after waits of 250 ms, 500 ms, 1 s, 1.5 s and then every 2 s. For a PollInterval p the waits are p, 2p, 4p, 6p and then 8p, each capped at 2 seconds, or at p when p is longer: 1 s waits 1 s and then every 2 s, and 3 s looks up every 3 s. A PollInterval below 100 ms is raised to 100 ms, a guard against unit mistakes (PollInterval: 250 is 250 ns, not 250 ms).
  • Last lookup at the deadline. A wait that would end after the deadline is shortened, so the last lookup runs at the deadline.
  • One attempt per lookup. Each lookup is a single HTTP attempt without the client's retries, with an attempt timeout of min(client timeout, max(time left, 1 s)). The lookup at the deadline still gets one second to answer, so Get can return up to one second after Timeout.
  • Transient errors keep polling. A 429 or 5xx answer, a network failure or an attempt timeout does not end the wait: the next lookup follows on the schedule.
  • 429 floor. After a 429 the next wait is the longest of the scheduled wait, 1 second and the Retry-After delay capped at 10 seconds. Without the header, and with Retry-After: 0 or a date in the past, the delay counts as 0 and the 1-second floor still applies. Near the deadline that wait is shortened like any other, so the last lookup still runs at the deadline. When the capped Retry-After delay alone would end after the deadline, the 429 error is returned at once.
  • 4xx stop. A 400, 401, 403 or 404 answer ends the wait at once with its error (ErrBadRequest, ErrAuthentication or ErrNotFound), because a wrong key or a wrong base URL does not heal.
  • Result. When the time is up, Get returns the error of the last lookup if it failed, otherwise (nil, nil). A missing identification means unverified, never clean.
  • First version. Get returns the first version of the row. For the refined state (for example in a later review job), read the row again with client.History.Search(ctx, shieldlabs.LookupRequestID, requestID, &shieldlabs.HistorySearchOptions{Limit: 1}).
Checking an identification before a protected action

EvaluateIdentification is a reusable guard. It never performs I/O and checks, in order:

Reason Refused when
missing the identification is nil
replayed your IsReplay callback reports the request ID as already used
stale it is older than MaxAge (default 5 minutes, by ObservedAt)
rate_limited the Risk Score is the rate-limit marker (above 100, in practice 999): ShieldLabs writes one such identification when a visitor IP goes over the identification rate limit, and request IDs issued while that IP is blocked get no identification at all (missing)
no_device_signals the device ID is the nil UUID 00000000-0000-0000-0000-000000000000
blocked_flag a flag in BlockFlags is set (default browser_automation, javascript_disabled)
blocked_band the band is in BlockBands (default dangerous)

The defaults are a starting point: tune them for your traffic. Nil BlockFlags or BlockBands select the defaults, an empty slice blocks nothing. The SDK stores no state: IsReplay should record each request ID atomically in your own store (a database table or a cache with expiry) and return true when it was already there.

Risk bands are computed on the client: trusted 0-29, suspicious 30-59, dangerous 60-100. RiskBand(score) returns BandRateLimited for scores above 100, and IsRateLimited(score) reports the marker. Branch on RiskScore, the band and DetectionFlags; use Signals (the weighted risk signals behind the score) for display and logging. Weights can be negative, so never add them up yourself.

Searching history for account-abuse checks

The History API returns the identifications that match one identifier, newest first. Search by device ID to count accounts on one device, by User HID to see the devices and countries of one account, or by visitor ID or IP address:

// The nil device ID means "no usable device signals" and matches unrelated
// identifications, so only count accounts for a real device ID.
if ident.DeviceID != shieldlabs.NilUUID {
	page, err := client.History.Search(ctx, shieldlabs.LookupDeviceID, ident.DeviceID,
		&shieldlabs.HistorySearchOptions{Limit: 100})
	if err != nil {
		return err
	}
	accounts := map[string]bool{}
	for _, h := range page.Data {
		// AccountUserHID skips a missing User HID and the values that name
		// no account: "anonymous", "fail", "-1" and "unknown".
		if hid, ok := h.AccountUserHID(); ok {
			accounts[hid] = true
		}
	}
	if len(accounts) >= 3 {
		// several accounts share this device: review the signup
	}
}

IsSentinelUserHID(s) makes the same check for a plain string.

History.All iterates over every page and skips rows it has already yielded (rows can shift between pages while new identifications arrive):

for h, err := range client.History.All(ctx, shieldlabs.LookupUserHID, userHID,
	&shieldlabs.HistoryIterateOptions{MaxItems: 1000}) {
	if err != nil {
		return err
	}
	fmt.Println(h.ObservedAt, h.DeviceID, h.PublicIP.Country)
}

MaxItems stops the iteration after that many identifications. MaxItems: 0, the zero value, means no limit, so a nil options pointer iterates over every row.

Arguments are validated on the client before anything is sent, so a typo never turns into an unfiltered or failing query: the lookup type must be one of LookupIP, LookupUserHID, LookupVisitorID, LookupRequestID, LookupDeviceID, LookupSessionID, LookupCookieID; UUIDs must be UUIDs (sent lowercase); an IP must be a dotted IPv4 address; Limit is 1 to 100 (0 selects 20) and Offset is 0 or more. Invalid arguments return an error matching ErrValidation.

A User HID is matched exactly and case-sensitively. It is sent as one path segment in the escaped form the History API decodes, so values with @, +, =, ,, ; or spaces match as they are. A User HID that is empty, contains /, is . or .., or is not valid UTF-8 cannot be matched in the request path, so Search and All return ErrValidation for it instead of an empty page. User HIDs from UserHID are 64 hex characters and always work; if you build User HIDs another way, avoid / (standard base64 contains it, base64url does not).

Country values are English country names such as "Germany", or "" when unknown.

Webhooks

ShieldLabs posts identification.scored events to your endpoints, signed with X-Shield-Signature: sha256=<hex HMAC-SHA256 of the raw body>. The key is the full signing secret including its whsec_ prefix.

  • Verify the raw body: read it with io.ReadAll and pass the bytes. Parsing and re-encoding the JSON changes them.
  • ConstructEvent returns *IdentificationScoredEvent (with Data, a normalized Identification), *PingEvent (sent by the analytics dashboard's "Verify" button) or *UnknownEvent for event types this version does not know. Acknowledge them all.
  • Today ShieldLabs sends one delivery per identification and endpoint, with a 1-second timeout and no retries. Answer with a 2xx status within one second and do slow work afterwards or in a queue.
  • Make the handler idempotent on Data.RequestID: a later server release retries failed deliveries, and a retry resends identical bytes.
  • Use the History API for guaranteed reads and for the latest state: a failed delivery is not sent again, and a History row can be refined after its webhook was sent.
  • To rotate a secret without downtime, pass both secrets: webhook.ConstructEvent(body, header, newSecret, oldSecret).

The analytics dashboard "Test" button sends a sample identification.scored event with 17 of the 19 detection flags; missing flags are parsed as false.

User HID

Link identifications to your accounts with a User HID: a hashed or pseudonymous account ID that you pass to the browser agent. Never pass a raw email address or database ID. UserHID computes a stable, irreversible value on your server:

// userHIDSecret is a secret of your own that never reaches the browser.
hid, err := shieldlabs.UserHID(user.ID, userHIDSecret) // 64 lowercase hex characters
Management profile
mgmt, err := shieldlabs.NewManagementClient(os.Getenv("SHIELDLABS_SECRET_KEY"), os.Getenv("SHIELDLABS_DOMAIN"))
if err != nil {
	return err
}
profile, err := mgmt.GetProfile(ctx)
if err != nil {
	return err
}
fmt.Println(profile.Domain, profile.RemainingIdentifications, profile.CreatedAt)

The domain is normalized before it is sent ("https://www.Example.com/" becomes "example.com"), because the API matches the registered domain exactly. RemainingIdentifications can be negative when the account is over its included volume. Cache the profile: see the rate limits below.

Rate limits
API Limit What the SDK does
History API about 15 requests per second per domain, shared by all your servers Inside the wait of Identifications.Get, a 429 waits at least one second before the next lookup, longer when the scheduled wait or Retry-After (up to 10 seconds) asks for more. Ordinary calls (Search, All and a NoWait lookup) follow Retry-After as sent, up to 10 seconds (Retry-After: 0 or a date in the past retries at once), and retry a 429 without Retry-After after at least one second
Management API about 15 requests per minute per caller IP, then that IP is blocked for 10 minutes never retries a 429, because a retry only runs into the block

A waiting Identifications.Get makes at most 9 lookups in its default 10-second wait (4 in the first 2 seconds, then one every 2 seconds and the last one at the deadline) and usually far fewer, because the row appears about 1 to 3 seconds after the browser call. Starting the identification early keeps that number low. Call the Management API sparingly and cache its answers.

Configuration and concurrency
client, err := shieldlabs.NewClient(apiKey,
	shieldlabs.WithBaseURL(os.Getenv("SHIELDLABS_API_BASE_URL")), // empty keeps https://account.shieldlabs.ai
	shieldlabs.WithHTTPClient(&http.Client{Transport: myTransport}),
	shieldlabs.WithTimeout(5*time.Second),                        // per HTTP attempt, default 10 s
	shieldlabs.WithMaxRetries(3),                                 // default 2
	shieldlabs.WithLogger(slog.Default()),                        // configuration warnings only
)

The History base URL is the origin; request paths start with /api/v1/history, and a trailing /api on the base URL is removed. The Management API origin is https://api.shieldlabs.ai. Base URLs must use https, because every request carries a key. Plain http is accepted for localhost, 127.0.0.0/8 and [::1] (local mock servers); WithInsecureHTTP() allows it for a test server on another host and logs a warning. Keys, secrets and domains that contain whitespace or control characters (a bad copy and paste) are rejected with ErrValidation.

Every client method takes a context.Context for cancellation and deadlines. Clients are safe for concurrent use by multiple goroutines: create one per key and reuse it. The SDK sends Authorization, Accept: application/json and User-Agent: shieldlabs-go/<version>, collects no telemetry and never logs keys, secrets or response bodies.

Reference

Full documentation: pkg.go.dev and docs.shieldlabs.ai.

API Description
NewClient(apiKey, ...Option) (*Client, error) History API client (Private API Key)
WithBaseURL, WithHTTPClient, WithTimeout, WithMaxRetries, WithLogger, WithInsecureHTTP Options for both clients
(*IdentificationsService).Get(ctx, requestID, *GetIdentificationOptions) (*Identification, error) One identification by request ID, waiting for the verdict unless NoWait is set; (nil, nil) when not found in time
GetIdentificationOptions{NoWait, Timeout, PollInterval} The zero value waits: Timeout 0 means 10 s, PollInterval 0 means 250 ms (values below 100 ms become 100 ms); waits are p, 2p, 4p, 6p, then 8p, each capped at max(2 s, p)
(*HistoryService).Search(ctx, LookupType, value, *HistorySearchOptions) (*HistoryPage, error) One page of identifications for an identifier
(*HistoryService).All(ctx, LookupType, value, *HistoryIterateOptions) iter.Seq2[*Identification, error] Iterator over every page, deduplicated on request ID
HistorySearchOptions{Limit, Offset}, HistoryIterateOptions{PageSize, MaxItems} Limit 0 means 20, PageSize 0 means 100, MaxItems 0 means no limit
NewManagementClient(secretKey, domain, ...Option) (*ManagementClient, error) Management API client (Secret Key and domain)
(*ManagementClient).GetProfile(ctx) (*DomainProfile, error) Domain profile
webhook.VerifySignature(payload, header, ...secrets) bool Constant-time signature check, one or more secrets
webhook.ConstructEvent(payload, header, ...secrets) (webhook.Event, error) Verified, typed event
EvaluateIdentification(*Identification, EvaluateOptions) Evaluation Guard policy with a refusal reason
RiskBand(score) Band, IsRateLimited(score) bool Band helpers
UserHID(userID, secret) (string, error) HMAC-SHA256 User HID
(*Identification).AccountUserHID() (string, bool), IsSentinelUserHID(s) bool Skip User HID values that name no account when counting accounts
ParseHistoryRow([]byte), ParseWebhookData([]byte) Normalize a History row or webhook data into an Identification
SignalSlug(description) string Signal name for a History signal description
Identification, DetectionFlags, Signal, IPInfo, TrafficSource, DomainProfile, HistoryPage Data model; JSON field names follow the webhook contract

Identification fields: RequestID, VisitorID, DeviceID, SessionID, CookieID, UserHID (nil when none), Domain, PublicIP, LocalIP (each {IP, Country}), ConnectionType (direct, mobile, vpn, proxy, tor, privacy_relay, browser_vpn_proxy, unknown), OS, Browser, DeviceType, TrafficSource, RiskScore, Signals, DetectionFlags (19 booleans), ObservedAt (UTC), Source (webhook or history) and Raw (the original object). Unknown values are kept as they arrive.

Errors and retries

Errors work with errors.Is and errors.As:

Condition Match with Retried
Invalid argument, nothing sent ErrValidation no
HTTP 400 ErrBadRequest no
HTTP 401, 403 (wrong or disabled key, wrong domain) ErrAuthentication no
HTTP 402 (no requests left on the account) ErrQuotaExceeded no
HTTP 404 (unknown route) ErrNotFound no
HTTP 429 ErrRateLimited, APIError.RetryAfter History: yes; Management: never
HTTP 5xx ErrServer yes
Network failure ErrConnection yes
No response within the attempt timeout ErrTimeout yes
Invalid webhook signature webhook.ErrSignature not applicable
Signed but invalid webhook body webhook.ErrParse not applicable

Every HTTP error is an *APIError with StatusCode, Message (parsed from the body), Body, Header and RetryAfter (the delay the Retry-After header asks for, in seconds or as an HTTP date; 0 when the header is absent, invalid, 0 or a date in the past):

var apiErr *shieldlabs.APIError
if errors.As(err, &apiErr) {
	log.Printf("ShieldLabs answered %d: %s", apiErr.StatusCode, apiErr.Message)
}

Only GET requests are sent, and failed attempts are retried up to WithMaxRetries times (default 2) with exponential backoff (0.5 s doubling up to 8 s, randomized) or the delay the server's Retry-After asks for, as sent (capped at 10 s): Retry-After: 0 or a date in the past retries at once. A 429 without a valid Retry-After waits at least 1 s, because the History limit counts requests per one-second window. The lookups of a waiting Identifications.Get are single attempts: instead of retrying, the wait looks up again on its schedule (see Waiting for the verdict). A cancelled or expired context stops at once and matches context.Canceled or context.DeadlineExceeded.

Compatibility

  • Go 1.23 or later (the History iterator uses range-over-func). CI runs Go 1.23, 1.24, 1.25 and the two current stable releases.
  • Standard library only, no third-party dependencies.
  • Webhook schema version 2026-06-01. Unknown fields, flags, connection types, risk signals, event types and schema versions are tolerated.
  • Semantic versioning: breaking changes only in a new major version.

Development

# with a local Go toolchain
test -z "$(gofmt -l .)" && go vet ./... && go test -race -cover ./... && go build ./...

# or in Docker
docker run --rm -v "$PWD":/src -w /src golang:1.24-bookworm \
  sh -c 'test -z "$(gofmt -l .)" && go vet ./... && go test -race -cover ./... && go build ./...'

testdata/ holds the shared test fixtures (History pages, webhook deliveries, signature vectors, error bodies) that every ShieldLabs server SDK passes. See CONTRIBUTING.md.

Questions or issues: contact@shieldlabs.ai.

License

MIT

Documentation

Overview

Package shieldlabs is the ShieldLabs server SDK for Go.

A ShieldLabs integration has three steps:

  1. In the browser, the ShieldLabs agent runs an identification and hands your page a request ID. The browser never sees a Risk Score or a device ID.
  2. Your backend receives the request ID together with the protected action (signup, login, checkout) and reads the verdict for it from the History API, or receives the verdict in a signed webhook.
  3. Your backend decides what to do with the verdict: the Risk Score, the risk band, the detection flags and the identifiers.

This package covers step 2 and helps with step 3:

Webhook deliveries and History rows are normalized into one model, Identification, whose JSON field names follow the webhook contract.

Every client is safe for concurrent use by multiple goroutines. Create one client per key and reuse it.

Errors can be inspected with errors.Is and errors.As: API responses with an error status are returned as *APIError and match sentinels such as ErrAuthentication or ErrRateLimited; invalid arguments match ErrValidation and are reported before any request is sent.

Index

Examples

Constants

View Source
const (
	FlagVPN                 = "vpn"
	FlagPrivacyRelay        = "privacy_relay"
	FlagBrowserVPNProxy     = "browser_vpn_proxy"
	FlagTor                 = "tor"
	FlagProxy               = "proxy"
	FlagDatacenterIP        = "datacenter_ip"
	FlagAbuser              = "abuser"
	FlagOSMismatch          = "os_mismatch"
	FlagOSNotDetected       = "os_not_detected"
	FlagTimezoneMismatch    = "timezone_mismatch"
	FlagAntiDetectBrowser   = "anti_detect_browser"
	FlagBrowserAutomation   = "browser_automation"
	FlagIPMismatch          = "ip_mismatch"
	FlagIncognito           = "incognito"
	FlagSearchBot           = "search_bot"
	FlagSuspiciousPaidClick = "suspicious_paid_click"
	FlagJavaScriptDisabled  = "javascript_disabled"
	FlagStunNotChecked      = "stun_not_checked"
	FlagCheckIncomplete     = "check_incomplete"
)

Detection flag names as they appear on the wire. Use them with DetectionFlags.Get and EvaluateOptions.BlockFlags.

View Source
const (
	// DefaultBaseURL is the origin of the History API. Request paths start
	// with /api/v1/history.
	DefaultBaseURL = "https://account.shieldlabs.ai"

	// DefaultManagementBaseURL is the origin of the Management API. Request
	// paths start with /v1.
	DefaultManagementBaseURL = "https://api.shieldlabs.ai"

	// NilUUID is the all-zero UUID. As a device ID it means that the
	// identification had no usable device signals; ban marker rows also use it
	// for other identifiers.
	NilUUID = "00000000-0000-0000-0000-000000000000"
)
View Source
const (
	SignalTor                    = "tor"
	SignalJavaScriptDisabled     = "javascript_disabled"
	SignalOSMismatch             = "os_mismatch"
	SignalAntidetectBrowser      = "antidetect_browser"
	SignalProxyRoutedAntidetect  = "proxy_routed_antidetect"
	SignalPortScanRoutedViaProxy = "port_scan_routed_via_proxy"
	SignalBrowserAutomation      = "browser_automation"
	SignalStunNotChecked         = "stun_not_checked"
	SignalStunLateCorrection     = "stun_late_correction"
	SignalOSNotDetected          = "os_not_detected"
	SignalBrowserVPNProxy        = "browser_vpn_proxy"
	SignalVPN                    = "vpn"
	SignalPrivacyRelay           = "privacy_relay"
	SignalProxy                  = "proxy"
	SignalDatacenterIP           = "datacenter_ip"
	SignalAbuser                 = "abuser"
	SignalTimezoneMismatch       = "timezone_mismatch"
	SignalRateLimited            = "rate_limited"
)

Known risk signal names (Signal.Name). The set is open: new signals can appear at any time, so treat names as strings and never as a closed enum.

View Source
const DefaultMaxAge = 5 * time.Minute

DefaultMaxAge is the default freshness window of EvaluateIdentification.

View Source
const Version = "1.0.0"

Version is the version of this module.

Variables

View Source
var (
	// ErrValidation reports an invalid argument. Nothing was sent.
	ErrValidation = errors.New("shieldlabs: invalid argument")

	// ErrBadRequest matches HTTP 400.
	ErrBadRequest = errors.New("shieldlabs: bad request")

	// ErrAuthentication matches HTTP 401 and 403: a missing, wrong or
	// disabled key, or a domain that does not match the key.
	ErrAuthentication = errors.New("shieldlabs: authentication failed")

	// ErrQuotaExceeded matches HTTP 402: the account has no requests left.
	ErrQuotaExceeded = errors.New("shieldlabs: quota exceeded")

	// ErrNotFound matches HTTP 404: an unknown route. An identification that
	// has not been found is not an error (see IdentificationsService.Get).
	ErrNotFound = errors.New("shieldlabs: not found")

	// ErrRateLimited matches HTTP 429. APIError.RetryAfter holds the
	// Retry-After delay when the server sent one.
	ErrRateLimited = errors.New("shieldlabs: rate limited")

	// ErrServer matches HTTP 5xx.
	ErrServer = errors.New("shieldlabs: server error")

	// ErrConnection reports a network failure: the request did not reach
	// ShieldLabs or the connection broke before a full response arrived.
	ErrConnection = errors.New("shieldlabs: connection failed")

	// ErrTimeout reports that an HTTP attempt ran out of time without a
	// response.
	ErrTimeout = errors.New("shieldlabs: request timed out")
)

Sentinel errors. Match them with errors.Is:

if errors.Is(err, shieldlabs.ErrRateLimited) { ... }

ErrBadRequest, ErrAuthentication, ErrQuotaExceeded, ErrNotFound, ErrRateLimited and ErrServer match an *APIError with the corresponding HTTP status. ErrConnection and ErrTimeout wrap network failures, and ErrValidation reports an invalid argument detected before any request was sent.

Functions

func DetectionFlagNames

func DetectionFlagNames() []string

DetectionFlagNames returns the 19 detection flag names in wire order.

func IsRateLimited

func IsRateLimited(score int) bool

IsRateLimited reports whether a Risk Score is the rate-limit marker (a value above 100, in practice 999). ShieldLabs writes one such identification when a visitor IP goes over the identification rate limit; it carries no real verdict. Request IDs issued while that IP is blocked get no identification at all, so IdentificationsService.Get returns nil for them: treat them as unverified.

func IsSentinelUserHID

func IsSentinelUserHID(userHID string) bool

IsSentinelUserHID reports whether a User HID value names no account: the empty string, "anonymous" (anonymous checks) and the sentinel values "fail", "-1" and "unknown". The comparison is exact and case-sensitive.

Example
package main

import (
	"fmt"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	for _, hid := range []string{"9f86d081884c7d659a2feaa0c55ad015", "anonymous", "unknown"} {
		fmt.Println(hid, shieldlabs.IsSentinelUserHID(hid))
	}
}
Output:
9f86d081884c7d659a2feaa0c55ad015 false
anonymous true
unknown true

func SignalSlug

func SignalSlug(description string) string

SignalSlug returns the signal name that ShieldLabs derives from a signal description. History rows carry descriptions (score_details) while webhooks carry names; the History client uses this function so that both surfaces report the same names.

Known descriptions map to fixed names. Any other description is slugified: the text before the first "(" is lowercased, spaces, "-" and "/" become "_", "≠" becomes "_neq_" and other punctuation is dropped. An empty result becomes "unknown".

Example
package main

import (
	"fmt"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	fmt.Println(shieldlabs.SignalSlug("Is VPN"))
	fmt.Println(shieldlabs.SignalSlug("Antidetect browser (turn_block)"))
}
Output:
vpn
antidetect_browser

func UserHID

func UserHID(userID, secret string) (string, error)

UserHID derives a stable, irreversible User HID from your own user ID: the lowercase hex HMAC-SHA256 of userID keyed with secret (64 characters). Compute it on your server with a secret that never reaches the browser, then pass the result to the browser agent instead of a raw email address or account ID.

It returns an error matching ErrValidation when userID or secret is empty.

Example
package main

import (
	"fmt"
	"log"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	hid, err := shieldlabs.UserHID("user-42", "server-side-secret")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(hid)
}
Output:
7900fe533a0c4a3231be8bf94641b2f0b20bd2af0ef7bf2eadb759233cc3d822

Types

type APIError

type APIError struct {
	// StatusCode is the HTTP status code.
	StatusCode int
	// Message is the error message parsed from the body: the "error" field of
	// a JSON object, a bare JSON string, or a short plain-text body. It is ""
	// when the body carries no message.
	Message string
	// Body is the raw response body.
	Body []byte
	// Header holds the response headers.
	Header http.Header
	// RetryAfter is the delay the Retry-After header asks for, given in
	// seconds or as an HTTP date. It is 0 when the header is absent or
	// invalid, and when it asks for no delay: 0 or a date in the past.
	RetryAfter time.Duration
	// contains filtered or unexported fields
}

APIError is returned when a ShieldLabs API answers with an error status, or with a response body the SDK cannot read. It matches the sentinel for its status code with errors.Is.

func (*APIError) Error

func (e *APIError) Error() string

Error returns a one-line description, for example "shieldlabs: HTTP 429 Too Many Requests: too many requests".

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is reports whether target is the sentinel error for e's status code.

type Band

type Band string

Band is a risk band derived from a Risk Score. Bands are computed on the client; they are not part of the wire format.

const (
	// BandTrusted covers scores 0 to 29.
	BandTrusted Band = "trusted"
	// BandSuspicious covers scores 30 to 59.
	BandSuspicious Band = "suspicious"
	// BandDangerous covers scores 60 to 100.
	BandDangerous Band = "dangerous"
	// BandRateLimited marks a score above 100 (999): the rate-limit marker,
	// which is not a score.
	BandRateLimited Band = "rate_limited"
)

Risk bands.

func RiskBand

func RiskBand(score int) Band

RiskBand returns the band of a Risk Score: trusted (0-29), suspicious (30-59) or dangerous (60-100). Scores above 100 are the rate-limit marker and return BandRateLimited.

Example
package main

import (
	"fmt"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	for _, score := range []int{0, 30, 60, 999} {
		fmt.Println(score, shieldlabs.RiskBand(score))
	}
}
Output:
0 trusted
30 suspicious
60 dangerous
999 rate_limited

type Client

type Client struct {
	// Identifications reads one identification by its request ID.
	Identifications *IdentificationsService
	// History searches identifications by device, user, visitor, IP address,
	// session, cookie or request ID.
	History *HistoryService
	// contains filtered or unexported fields
}

Client reads identifications from the History API with a Private API Key (sec_...). Each Private API Key reads the identifications of one domain.

A Client is safe for concurrent use by multiple goroutines. Create it once and reuse it.

func NewClient

func NewClient(apiKey string, opts ...Option) (*Client, error)

NewClient returns a History API client for the given Private API Key.

It returns an error matching ErrValidation when the key is empty or contains whitespace or control characters (surrounding whitespace is trimmed), or when an option is invalid. A key that does not look like a Private API Key (sec_xxxxxxxx-xxxxxxxx-xxxxxxxx) only produces a warning on the configured logger, because the key format may change.

Defaults: base URL DefaultBaseURL, a 10-second timeout per HTTP attempt and 2 retries.

Example
package main

import (
	"log"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	client, err := shieldlabs.NewClient("sec_your_private_key")
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}

type ConnectionType

type ConnectionType string

ConnectionType is the connection class of an identification. The set is open: values other than the constants below are kept as they arrive.

const (
	ConnectionTypeDirect          ConnectionType = "direct"
	ConnectionTypeMobile          ConnectionType = "mobile"
	ConnectionTypeVPN             ConnectionType = "vpn"
	ConnectionTypeProxy           ConnectionType = "proxy"
	ConnectionTypeTor             ConnectionType = "tor"
	ConnectionTypePrivacyRelay    ConnectionType = "privacy_relay"
	ConnectionTypeBrowserVPNProxy ConnectionType = "browser_vpn_proxy"
	ConnectionTypeUnknown         ConnectionType = "unknown"
)

Known connection types.

type DetectionFlags

type DetectionFlags struct {
	VPN                 bool `json:"vpn"`
	PrivacyRelay        bool `json:"privacy_relay"`
	BrowserVPNProxy     bool `json:"browser_vpn_proxy"`
	Tor                 bool `json:"tor"`
	Proxy               bool `json:"proxy"`
	DatacenterIP        bool `json:"datacenter_ip"`
	Abuser              bool `json:"abuser"`
	OSMismatch          bool `json:"os_mismatch"`
	OSNotDetected       bool `json:"os_not_detected"`
	TimezoneMismatch    bool `json:"timezone_mismatch"`
	AntiDetectBrowser   bool `json:"anti_detect_browser"`
	BrowserAutomation   bool `json:"browser_automation"`
	IPMismatch          bool `json:"ip_mismatch"`
	Incognito           bool `json:"incognito"`
	SearchBot           bool `json:"search_bot"`
	SuspiciousPaidClick bool `json:"suspicious_paid_click"`
	JavaScriptDisabled  bool `json:"javascript_disabled"`
	StunNotChecked      bool `json:"stun_not_checked"`
	CheckIncomplete     bool `json:"check_incomplete"`
}

DetectionFlags holds the 19 stable detection booleans of an identification. Flags missing from a delivery are false.

func (DetectionFlags) Get

func (f DetectionFlags) Get(name string) (value, ok bool)

Get returns the flag with the given wire name, for example FlagBrowserAutomation. ok is false for an unknown name.

func (DetectionFlags) Map

func (f DetectionFlags) Map() map[string]bool

Map returns the 19 flags keyed by their wire names.

type DomainProfile

type DomainProfile struct {
	// Domain is the registered domain.
	Domain string `json:"domain"`
	// RemainingIdentifications is the number of included identifications left
	// on the account. It can be negative when the account is over its
	// included volume.
	RemainingIdentifications int64 `json:"remaining_identifications"`
	// PublicKeyMasked is the public key with every character except the last
	// 4 replaced by "*".
	PublicKeyMasked string `json:"public_key_masked"`
	// SecretKeyMasked is the Secret Key, masked the same way.
	SecretKeyMasked string `json:"secret_key_masked"`
	// CreatedAt is when the domain was registered (UTC), or the zero time
	// when unknown.
	CreatedAt time.Time `json:"created_at"`
	// Raw is the original response object, including fields this model does
	// not cover. Numbers are json.Number values.
	Raw map[string]any `json:"-"`
}

DomainProfile describes a registered domain.

type EvaluateOptions

type EvaluateOptions struct {
	// MaxAge is the freshness window measured from ObservedAt. Zero means
	// DefaultMaxAge (5 minutes); a negative value disables the check.
	MaxAge time.Duration
	// Now is the current time. The zero value means time.Now().
	Now time.Time
	// BlockBands lists the bands to refuse. Nil means [BandDangerous]; an
	// empty, non-nil slice refuses no band.
	BlockBands []Band
	// BlockFlags lists detection flag names to refuse, checked in order. Nil
	// means FlagBrowserAutomation and FlagJavaScriptDisabled; an empty,
	// non-nil slice refuses no flag. Unknown names are ignored.
	BlockFlags []string
	// IsReplay reports whether a request ID was used before. The SDK stores
	// nothing itself: record each request ID in your own store (atomically)
	// and return true when it was already there. Return true as well when the
	// store cannot be reached, so that failures refuse rather than allow.
	IsReplay func(requestID string) bool
}

EvaluateOptions tunes EvaluateIdentification. The zero value applies the defaults, which are a starting point to adjust for your traffic.

type Evaluation

type Evaluation struct {
	// OK is true when no check refused the identification.
	OK bool `json:"ok"`
	// Reason is the first check that refused it, or "" when OK.
	Reason Reason `json:"reason"`
	// Band is the risk band of the identification, or "" when it is missing.
	Band Band `json:"band"`
	// Flag is the blocking flag when Reason is ReasonBlockedFlag.
	Flag string `json:"flag,omitempty"`
}

Evaluation is the result of EvaluateIdentification.

func EvaluateIdentification

func EvaluateIdentification(ident *Identification, opts EvaluateOptions) Evaluation

EvaluateIdentification applies a reusable guard policy to an identification before a protected action such as a signup. It checks, in this order:

  1. missing: ident is nil (never treat a missing identification as clean);
  2. replayed: opts.IsReplay reports the request ID as already used;
  3. stale: the identification is older than opts.MaxAge;
  4. rate_limited: the Risk Score is the rate-limit marker;
  5. no_device_signals: the device ID is the nil UUID;
  6. blocked_flag: a flag in opts.BlockFlags is set;
  7. blocked_band: the risk band is in opts.BlockBands.

It never performs I/O.

Example
package main

import (
	"fmt"
	"time"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
	ident := &shieldlabs.Identification{
		RequestID:  "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53",
		DeviceID:   "ac7c303d-971b-41d1-8e25-cd5b46b46aed",
		RiskScore:  65,
		ObservedAt: now.Add(-20 * time.Second),
	}
	used := map[string]bool{}
	verdict := shieldlabs.EvaluateIdentification(ident, shieldlabs.EvaluateOptions{
		Now: now,
		IsReplay: func(requestID string) bool {
			seen := used[requestID]
			used[requestID] = true
			return seen
		},
	})
	fmt.Println(verdict.OK, verdict.Reason, verdict.Band)

	verdict = shieldlabs.EvaluateIdentification(nil, shieldlabs.EvaluateOptions{})
	fmt.Println(verdict.OK, verdict.Reason)
}
Output:
false blocked_band dangerous
false missing

type GetIdentificationOptions

type GetIdentificationOptions struct {
	// NoWait makes a single lookup instead of waiting for the verdict. That
	// lookup is retried like any History request, and Timeout and
	// PollInterval do not apply.
	NoWait bool
	// Timeout is the total time budget of the wait. Zero means 10 seconds.
	// The last lookup runs at the deadline and gets at least one second to
	// answer (or the client timeout when that is shorter), so Get can return
	// up to one second after Timeout.
	Timeout time.Duration
	// PollInterval sets the waits between lookups: for an interval p they are
	// p, 2p, 4p, 6p and then 8p, each capped at 2 seconds or at p when p is
	// longer. The default 250 ms gives 250 ms, 500 ms, 1 s, 1.5 s, then 2 s;
	// 1 s gives 1 s, then 2 s; 3 s gives a lookup every 3 s. Zero means
	// 250 ms. Values below 100 ms are raised to 100 ms, a guard against unit
	// mistakes such as PollInterval: 250, which is 250 ns.
	PollInterval time.Duration
}

GetIdentificationOptions controls IdentificationsService.Get. The zero value, like a nil *GetIdentificationOptions, waits for the verdict with the defaults.

type HistoryIterateOptions

type HistoryIterateOptions struct {
	// PageSize is the number of rows requested per page, 1 to 100. Zero means
	// 100.
	PageSize int
	// MaxItems stops the iteration after this many identifications. Zero
	// means no limit.
	MaxItems int
}

HistoryIterateOptions controls HistoryService.All.

type HistoryPage

type HistoryPage struct {
	// Data holds the identifications of this page.
	Data []*Identification `json:"data"`
	// Total is the number of rows matching the search.
	Total int64 `json:"total"`
}

HistoryPage is one page of History results, newest first.

type HistorySearchOptions

type HistorySearchOptions struct {
	// Limit is the page size, 1 to 100. Zero means the default of 20.
	Limit int
	// Offset is the number of rows to skip, 0 or more.
	Offset int
}

HistorySearchOptions controls one History page.

type HistoryService

type HistoryService struct {
	// contains filtered or unexported fields
}

HistoryService searches the History API. Get it from Client.History.

func (*HistoryService) All

All returns an iterator over every identification whose lookup identifier equals value, newest first, fetching pages of opts.PageSize rows as it goes. It stops after the last row (as reported by the page total), at an empty page, after opts.MaxItems identifications, or when the loop breaks.

Rows can shift between pages while new identifications arrive, so the iterator skips identifications it has already yielded (same request ID).

When a request fails, or the arguments are invalid, the iterator yields a nil identification with the error and stops:

for ident, err := range client.History.All(ctx, shieldlabs.LookupDeviceID, deviceID, nil) {
	if err != nil {
		return err
	}
	fmt.Println(ident.RequestID, ident.RiskScore)
}
Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	client, err := shieldlabs.NewClient("sec_your_private_key")
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	for ident, err := range client.History.All(ctx, shieldlabs.LookupUserHID, "9f86d081884c7d659a2feaa0c55ad015",
		&shieldlabs.HistoryIterateOptions{MaxItems: 500}) {
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(ident.ObservedAt.Format(time.RFC3339), ident.DeviceID, ident.PublicIP.Country)
	}
}

func (*HistoryService) Search

func (s *HistoryService) Search(ctx context.Context, lookup LookupType, value string, opts *HistorySearchOptions) (*HistoryPage, error)

Search returns one page of identifications whose lookup identifier equals value, newest first. Use it for account-abuse checks, for example to count how many accounts one device ID has signed up.

Arguments are validated on the client before anything is sent, so that a typo never turns into an unfiltered or failing query: the lookup type must be one of the seven Lookup constants, UUID values must be UUIDs (sent lowercase), an IP must be a dotted IPv4 address, Limit must be 0 to 100 and Offset must not be negative. A User HID is matched exactly; it must be non-empty, valid UTF-8 text without "/" and other than "." and "..", because the History API cannot match such values in the request path. Invalid arguments return an error matching ErrValidation.

Example
package main

import (
	"context"
	"fmt"
	"log"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	client, err := shieldlabs.NewClient("sec_your_private_key")
	if err != nil {
		log.Fatal(err)
	}
	// How many identifications, and how many distinct accounts, has this
	// device? Skip the nil device ID (no usable device signals): it matches
	// unrelated identifications.
	deviceID := "ac7c303d-971b-41d1-8e25-cd5b46b46aed"
	if deviceID == shieldlabs.NilUUID {
		return
	}
	page, err := client.History.Search(context.Background(), shieldlabs.LookupDeviceID, deviceID,
		&shieldlabs.HistorySearchOptions{Limit: 100})
	if err != nil {
		log.Fatal(err)
	}
	accounts := map[string]bool{}
	for _, ident := range page.Data {
		// AccountUserHID skips nil and the values that name no account
		// ("anonymous", "fail", "-1", "unknown").
		if hid, ok := ident.AccountUserHID(); ok {
			accounts[hid] = true
		}
	}
	fmt.Printf("%d identifications, %d accounts on this device\n", page.Total, len(accounts))
}

type IPInfo

type IPInfo struct {
	// IP is a dotted IPv4 address, or "" when none is known (for example for
	// visitors on IPv6).
	IP string `json:"ip"`
	// Country is the English country name, for example "Germany", or ""
	// when unknown.
	Country string `json:"country"`
}

IPInfo is an IP address and its country.

type Identification

type Identification struct {
	// RequestID is the UUID the browser agent returned for this
	// identification. Use it to join the browser call, the webhook and the
	// History row, and to make sure one identification authorizes only one
	// protected action.
	RequestID string `json:"request_id"`

	// VisitorID is the server-side visitor identifier. It is sticky to the
	// device: a new cookie on a known device usually keeps the visitor ID.
	VisitorID string `json:"visitor_id"`

	// DeviceID is the server-side device identifier. It survives cleared
	// cookies and private windows. [NilUUID] means that the identification
	// had no usable device signals.
	DeviceID string `json:"device_id"`

	// SessionID identifies one visit on one origin.
	SessionID string `json:"session_id"`

	// CookieID is the first-party browser identifier kept by the agent.
	CookieID string `json:"cookie_id"`

	// UserHID is the hashed or pseudonymous account ID your page passed to the
	// agent. It is nil when none was stored. Anonymous checks use the value
	// "anonymous"; "fail", "-1" and "unknown" are other values that name no
	// account. Use [Identification.AccountUserHID] to skip all of them, for
	// example when counting the accounts of one device.
	UserHID *string `json:"user_hid"`

	// Domain is the registered domain the identification belongs to.
	Domain string `json:"domain"`

	// PublicIP is the IP address of the HTTP request and its country.
	PublicIP IPInfo `json:"public_ip"`

	// LocalIP is the local network address observed during the network
	// checks, and its country.
	LocalIP IPInfo `json:"local_ip"`

	// ConnectionType is the connection class, for example
	// [ConnectionTypeVPN]. Unknown values are kept.
	ConnectionType ConnectionType `json:"connection_type"`

	// OS is the operating system, for example "Windows" or "Mac OS X".
	OS string `json:"os"`

	// Browser is the browser, for example "Chrome" or "Safari".
	Browser string `json:"browser"`

	// DeviceType is "desktop", "mobile", "tablet" or "unknown".
	DeviceType string `json:"device_type"`

	// TrafficSource describes where the visit came from.
	TrafficSource TrafficSource `json:"traffic_source"`

	// RiskScore is an integer from 0 to 100. A value above 100 (999) is a
	// rate-limit marker, never a score: see [IsRateLimited]. Use [RiskBand]
	// for the band.
	RiskScore int `json:"risk_score"`

	// Signals lists the weighted risk signals behind the score, in the order
	// ShieldLabs reported them. Weights can be negative and names can repeat.
	// Use signals for display and logging, and branch on DetectionFlags and
	// RiskScore instead. Never add up the weights yourself.
	Signals []Signal `json:"signals"`

	// DetectionFlags holds the 19 stable detection booleans.
	DetectionFlags DetectionFlags `json:"detection_flags"`

	// ObservedAt is when the identification was observed, in UTC. For a
	// webhook it is the time the verdict was sent; for a History row it is
	// the time the identification was stored. It is the zero time when the
	// value could not be read.
	ObservedAt time.Time `json:"observed_at"`

	// Source tells whether the identification came from a webhook or from
	// the History API.
	Source Source `json:"source"`

	// Raw is the original webhook data object or History row, including
	// fields this model does not cover. Numbers are json.Number values so
	// that integers keep their exact value.
	Raw map[string]any `json:"-"`
}

Identification is one run of the ShieldLabs agent in one browser: one billable request and one History row. Webhook deliveries and History API rows are both normalized into this model, and its JSON field names follow the webhook contract.

Every field is always set. Unknown values (a new connection type, a new risk signal) are kept as they arrive.

func ParseHistoryRow

func ParseHistoryRow(row []byte) (*Identification, error)

ParseHistoryRow normalizes one row of a History API response (a JSON object) into an Identification. The History client already does this for you; the function is exported for rows obtained another way.

func ParseWebhookData

func ParseWebhookData(data []byte) (*Identification, error)

ParseWebhookData normalizes the data object of an identification.scored webhook event into an Identification. It does not verify a signature: use the webhook package to verify and parse a delivery in one step.

func (*Identification) AccountUserHID

func (i *Identification) AccountUserHID() (userHID string, ok bool)

AccountUserHID returns the User HID when it names one of your accounts. ok is false when UserHID is nil or one of the values that name no account (see IsSentinelUserHID). Use it when you count the accounts seen on one device, visitor or IP address:

accounts := map[string]bool{}
for _, ident := range page.Data {
	if hid, ok := ident.AccountUserHID(); ok {
		accounts[hid] = true
	}
}

func (*Identification) Band

func (i *Identification) Band() Band

Band returns the risk band of the identification's Risk Score.

func (*Identification) IsRateLimited

func (i *Identification) IsRateLimited() bool

IsRateLimited reports whether the Risk Score is the rate-limit marker.

type IdentificationsService

type IdentificationsService struct {
	// contains filtered or unexported fields
}

IdentificationsService reads single identifications. Get it from Client.Identifications.

func (*IdentificationsService) Get

Get returns the identification for a request ID, or (nil, nil) when none was found in time.

Scoring is asynchronous: the History row appears about 1 to 3 seconds after the browser call, and follow-up checks can refine it for up to about 10 seconds. Start the identification in the browser when the user begins the action (for example when the signup form opens), so that the verdict is usually ready when your backend asks for it.

Unless opts.NoWait is set, Get waits for the row, and opts.Timeout (10 s by default) is the total budget of the wait. Get looks up the request ID at once, then again after waits of 250 ms, 500 ms, 1 s, 1.5 s and then every 2 s (p, 2p, 4p, 6p and then 8p for a PollInterval p, each capped at max(2 s, p)), and the last lookup runs at the deadline. Each lookup is a single HTTP attempt, without the client's retries, and gets min(client timeout, max(time left, 1 s)) to answer.

A 429 or 5xx answer, a network failure or an attempt timeout does not end the wait: the next lookup follows on the same schedule. The History API allows about 15 requests per second per domain for all callers together, so after a 429 the next wait is the longest of the scheduled wait, one second and the Retry-After delay capped at 10 seconds. Without the header, and with "Retry-After: 0" or a date in the past, the delay counts as 0 and the one-second minimum still applies. Near the deadline that wait is shortened like any other, so that the last lookup runs at the deadline, but when the capped Retry-After delay alone would end after the deadline, Get returns the 429 error at once.

A 400, 401, 403 or 404 answer ends the wait at once with its error (ErrBadRequest, ErrAuthentication, ErrNotFound), because a wrong key or a wrong base URL does not heal, and so does any other answer that another lookup cannot change.

When the time is up, Get returns the error of the last lookup if it failed, and (nil, nil) otherwise. A nil result means "unverified", never "clean": treat it like a failed check. Get returns the first version of the row it finds; for the refined state, read the row again later with HistoryService.Search and LookupRequestID.

With opts.NoWait, Get makes a single lookup, retried like any History request.

The request ID must be a UUID; otherwise the error matches ErrValidation and nothing is sent.

Example
package main

import (
	"context"
	"fmt"
	"log"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	client, err := shieldlabs.NewClient("sec_your_private_key")
	if err != nil {
		log.Fatal(err)
	}
	// requestID comes from your page, sent together with the protected action.
	requestID := "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53"

	ident, err := client.Identifications.Get(context.Background(), requestID, nil)
	if err != nil {
		log.Fatal(err)
	}
	if ident == nil {
		fmt.Println("not scored in time: treat as unverified")
		return
	}
	fmt.Println(ident.RiskScore, ident.Band(), ident.DetectionFlags.VPN)
}

type LookupType

type LookupType string

LookupType selects the identifier a History search matches on.

const (
	// LookupIP matches the public IP address, written as a dotted IPv4
	// address.
	LookupIP LookupType = "ip"
	// LookupUserHID matches the User HID exactly (case-sensitive).
	LookupUserHID LookupType = "user_hid"
	// LookupVisitorID matches the visitor ID (UUID).
	LookupVisitorID LookupType = "visitor_id"
	// LookupRequestID matches the request ID (UUID).
	LookupRequestID LookupType = "request_id"
	// LookupDeviceID matches the device ID (UUID).
	LookupDeviceID LookupType = "device_id"
	// LookupSessionID matches the session ID (UUID).
	LookupSessionID LookupType = "session_id"
	// LookupCookieID matches the cookie ID (UUID).
	LookupCookieID LookupType = "cookie_id"
)

The seven History lookup types.

type ManagementClient

type ManagementClient struct {
	// contains filtered or unexported fields
}

ManagementClient calls the Management API with the Secret Key of one registered domain.

The Management API allows about 15 requests per minute per caller IP address and then blocks that IP for 10 minutes. Call it sparingly (cache the profile) and never in a loop. The client never retries a 429 answer, because retrying would only run into the block.

A ManagementClient is safe for concurrent use by multiple goroutines.

func NewManagementClient

func NewManagementClient(secretKey, domain string, opts ...Option) (*ManagementClient, error)

NewManagementClient returns a Management API client for a Secret Key and the domain it was issued for.

The domain is normalized before it is sent, because the API matches the registered domain exactly: surrounding spaces, a scheme, a path, a trailing slash and a leading "www." are removed and the rest is lowercased, so "https://www.Example.com/" becomes "example.com".

It returns an error matching ErrValidation when the secret key or the domain is empty or contains whitespace or control characters (surrounding whitespace is trimmed), or when an option is invalid. Defaults: base URL DefaultManagementBaseURL, a 10-second timeout per HTTP attempt and 2 retries (never on 429).

func (*ManagementClient) GetProfile

func (m *ManagementClient) GetProfile(ctx context.Context) (*DomainProfile, error)

GetProfile returns the profile of the client's domain (GET /v1/profile). An exhausted account answers with an error matching ErrQuotaExceeded, a wrong secret or domain with ErrAuthentication and the per-IP limit with ErrRateLimited.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	shieldlabs "github.com/ShieldLabs-ai/shieldlabs-go"
)

func main() {
	mgmt, err := shieldlabs.NewManagementClient("your_secret_key", "example.com")
	if err != nil {
		log.Fatal(err)
	}
	// About 15 requests per minute per IP: cache the result.
	profile, err := mgmt.GetProfile(context.Background())
	if errors.Is(err, shieldlabs.ErrRateLimited) {
		log.Fatal("Management API limit reached: wait before calling again")
	}
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(profile.Domain, profile.RemainingIdentifications)
}

type Option

type Option func(*config)

Option configures a Client or a ManagementClient.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL overrides the API origin, for example "https://dev.account.shieldlabs.ai" for the History API or a local test server. A trailing slash is removed. For the History client a trailing "/api" is removed too, because request paths already start with /api.

An empty value keeps the default, so an optional override can be passed straight from the environment:

shieldlabs.WithBaseURL(os.Getenv("SHIELDLABS_API_BASE_URL"))

The URL must use https. Plain http is accepted for localhost, 127.0.0.0/8 and [::1], and for other hosts only together with WithInsecureHTTP.

func WithHTTPClient

func WithHTTPClient(client *http.Client) Option

WithHTTPClient sets the *http.Client used for requests, for example to configure a proxy or a custom transport. The default is a new http.Client that uses http.DefaultTransport.

func WithInsecureHTTP

func WithInsecureHTTP() Option

WithInsecureHTTP accepts a plain http base URL on a host other than localhost, 127.0.0.0/8 or [::1], for example a mock server in a container network. Keys then travel unencrypted, so never use it in production; the client logs a warning when it is created with such a URL.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the logger used for configuration warnings, for example a Private API Key with an unexpected format. The default is slog.Default(). The SDK never logs keys, secrets or response bodies.

func WithMaxRetries

func WithMaxRetries(n int) Option

WithMaxRetries sets how many times a failed request is retried (default 2). Zero disables retries.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the timeout of each HTTP attempt (default 10 seconds). Zero disables the per-attempt timeout; the request context still applies.

type Reason

type Reason string

Reason explains why EvaluateIdentification refused an identification.

const (
	// ReasonMissing: no identification was found. Treat it as unverified,
	// never as clean.
	ReasonMissing Reason = "missing"
	// ReasonReplayed: the IsReplay callback reported the request ID as
	// already used.
	ReasonReplayed Reason = "replayed"
	// ReasonStale: the identification is older than MaxAge, or its time is
	// unknown.
	ReasonStale Reason = "stale"
	// ReasonRateLimited: the Risk Score is the rate-limit marker.
	ReasonRateLimited Reason = "rate_limited"
	// ReasonNoDeviceSignals: the device ID is the nil UUID.
	ReasonNoDeviceSignals Reason = "no_device_signals"
	// ReasonBlockedFlag: a flag listed in BlockFlags is set (see
	// Evaluation.Flag).
	ReasonBlockedFlag Reason = "blocked_flag"
	// ReasonBlockedBand: the risk band is listed in BlockBands.
	ReasonBlockedBand Reason = "blocked_band"
)

Refusal reasons, in the order EvaluateIdentification checks them.

type Signal

type Signal struct {
	// Name is the signal slug, for example [SignalProxy]. The set is open.
	Name string `json:"name"`
	// Weight is the signal's contribution. It can be negative (corrections).
	Weight int `json:"weight"`
	// Description is the human-readable description from a History row, or
	// nil for webhook signals. It is free text: never branch on it.
	Description *string `json:"description"`
}

Signal is one weighted risk signal behind a Risk Score.

type Source

type Source string

Source is the surface an Identification was read from.

const (
	// SourceWebhook marks an identification parsed from a webhook delivery.
	SourceWebhook Source = "webhook"
	// SourceHistory marks an identification read from the History API.
	SourceHistory Source = "history"
)

type TrafficSource

type TrafficSource struct {
	// Channel is the traffic channel, for example "Google Ads",
	// "Organic Search", "Referral" or "Direct".
	Channel string `json:"channel"`
	// ReferrerDomain is the referring site without "www.", or the crawler
	// name for search engine crawlers.
	ReferrerDomain string `json:"referrer_domain"`
	// LandingURL is the landing page URL without its fragment. It can contain
	// query string values.
	LandingURL string `json:"landing_url"`
	// ClickIDType is the ad click ID parameter found on the landing URL, for
	// example "gclid".
	ClickIDType string `json:"click_id_type"`
	UTMSource   string `json:"utm_source"`
	UTMMedium   string `json:"utm_medium"`
	UTMCampaign string `json:"utm_campaign"`
	UTMContent  string `json:"utm_content"`
	UTMTerm     string `json:"utm_term"`
}

TrafficSource describes where a visit came from. Every field is "" when unknown.

Directories

Path Synopsis
examples
nethttp command
Command nethttp is a minimal net/http server that shows both halves of a ShieldLabs integration:
Command nethttp is a minimal net/http server that shows both halves of a ShieldLabs integration:
internal
wire
Package wire holds the low-level parsing rules shared by the shieldlabs packages: whitespace trimming, timestamp formats and JSON decoding.
Package wire holds the low-level parsing rules shared by the shieldlabs packages: whitespace trimming, timestamp formats and JSON decoding.
Package webhook verifies and parses ShieldLabs webhook deliveries.
Package webhook verifies and parses ShieldLabs webhook deliveries.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL