Documentation
¶
Overview ¶
Package fingerly is the server-side Fingerly SDK.
It reads stored identification events with a secret key, verifies signed webhooks, and serves the browser SDK's requests from a route on your own domain. It has no dependencies outside the standard library.
client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))
event, err := client.Events.Get(ctx, requestID)
Secret keys and webhook secrets belong in server-side code only. A browser must never see either.
Index ¶
- Constants
- Variables
- func VerifyWebhook(secret string, payload []byte, timestamp, signature string, ...) bool
- type APIError
- type Client
- type ConnectionError
- type Event
- type EventListParams
- type EventPage
- type EventTrigger
- type EventsService
- type Option
- type Proxy
- type ProxyError
- type ProxyOptions
- type WebhookEvent
- type WebhookOption
Examples ¶
Constants ¶
const DefaultWebhookTolerance = 5 * time.Minute
DefaultWebhookTolerance is how far a delivery's timestamp may be from now before it is refused as a replay.
const Version = "0.1.1"
Version is the SDK version, reported to the API on every request.
Variables ¶
var ErrEventNotFound = errors.New("fingerly: event not found")
ErrEventNotFound matches a read for an event that does not exist: the request id was never issued for this key's environment, or the event is past retention. An event that is still being recorded matches ErrEventPending instead.
Compare with errors.Is; the concrete error is always an *APIError.
var ErrEventPending = errors.New("fingerly: event pending")
ErrEventPending matches a read for an event whose request id was accepted but which is not recorded yet (404 event_pending). It is not a verdict: try again after APIError.RetryAfter seconds, or verify asynchronously. The SDK never retries on its own.
Compare with errors.Is; the concrete error is always an *APIError.
var ErrInvalidRequestID = errors.New("fingerly: request id is not a Fingerly request id")
ErrInvalidRequestID is returned by EventsService.Get, before any request is made, for a request id that cannot name one event: the empty string, "." or "..". The id usually comes from the browser, so treat it as bad input.
var ErrResponseTooLarge = errors.New("fingerly: response body exceeds 4 MiB")
ErrResponseTooLarge is returned for an API answer over 4 MiB, which no event read produces. The body is not read past the limit.
var ErrTimeout = errors.New("fingerly: request timed out")
ErrTimeout matches a read whose deadline passed, whether the caller's or WithTimeout's. Compare with errors.Is.
Functions ¶
func VerifyWebhook ¶
func VerifyWebhook(secret string, payload []byte, timestamp, signature string, opts ...WebhookOption) bool
VerifyWebhook reports whether a delivery came from Fingerly and is recent.
Pass the exact bytes received, before any JSON decoding: re-encoding them changes the digest. During a signing-secret rotation Fingerly sends several comma-separated signatures, and any one of the first 32 matching is accepted.
body, _ := io.ReadAll(r.Body)
ok := fingerly.VerifyWebhook(secret, body,
r.Header.Get("X-Fingerly-Timestamp"),
r.Header.Get("X-Fingerly-Signature"))
Verification proves authenticity and freshness. It does not deduplicate, so persist processed WebhookEvent.ID values as well.
Malformed or missing input is a false result, never a panic.
Example ¶
Verify the raw bytes before decoding them: re-encoding changes the digest.
package main
import (
"encoding/json"
"io"
"net/http"
"os"
fingerly "github.com/fingerly-io/fingerly-go"
)
func main() {
http.HandleFunc("/webhooks/fingerly", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if !fingerly.VerifyWebhook(
os.Getenv("FINGERLY_WEBHOOK_SECRET"),
body,
r.Header.Get("X-Fingerly-Timestamp"),
r.Header.Get("X-Fingerly-Signature"),
) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var event fingerly.WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "unreadable event", http.StatusBadRequest)
return
}
// Deduplicate on the event ID: a delivery may arrive more than once.
enqueue(event.ID, body)
w.WriteHeader(http.StatusNoContent)
})
}
func enqueue(id string, body []byte) {}
Output:
Types ¶
type APIError ¶
type APIError struct {
// Status is the HTTP status code.
Status int
// Code is the stable machine-readable code, such as "event_not_found".
Code string
// Details carries extra context; its shape depends on Code.
Details any
// RequestID is the server-side trace id to quote in support requests.
RequestID string
// RetryAfter is how many seconds the API asked the caller to wait, from
// its Retry-After header, or nil when it sent none. The SDK never retries
// a read itself.
RetryAfter *int
}
APIError is returned for any non-2xx response from the Fingerly API.
The message carries only the HTTP status. Response bodies are never echoed, and any occurrence of the secret key in the metadata is redacted.
Match it with errors.As:
var apiErr *fingerly.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
// ...
}
Example ¶
Decide on the server's verdict, never on a score the browser supplied.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"
fingerly "github.com/fingerly-io/fingerly-go"
)
func main() {
client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))
decide := func(ctx context.Context, orderID, requestID string) (string, error) {
event, err := client.Events.Get(ctx, requestID)
var apiErr *fingerly.APIError
switch {
case errors.Is(err, fingerly.ErrEventPending):
// Not decided yet, not fraud: retry after apiErr.RetryAfter
// seconds or verify asynchronously.
return "pending", nil
case errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound:
return "refuse", nil
case err != nil:
return "", err
}
if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute {
return "refuse", nil
}
// An unscored event is not a clean one; treat it as unknown.
if !event.Scored() {
return "challenge", nil
}
switch event.SuspectLevel {
case "high":
return "review", nil
case "medium":
return "challenge", nil
}
return "allow", nil
}
verdict, err := decide(context.Background(), "8412", "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")
if err != nil {
log.Fatal(err)
}
fmt.Println(verdict)
}
Output:
type Client ¶
type Client struct {
// Events reads stored events under this client's secret key.
Events *EventsService
// contains filtered or unexported fields
}
Client reads events for one organization and environment.
Build one with New and reuse it: it holds a pooled HTTP connection and is safe for concurrent use.
func New ¶
New builds a client for a server-only secret key, fly_sk_{environment}_…, whose prefix selects the environment read.
New never returns an error, so it composes in a package-level var. An invalid key or option is reported by the first call made on the client, before any request is sent.
Example ¶
Build one client and reuse it: it holds a pooled connection and is safe for concurrent use.
package main
import (
"context"
"fmt"
"log"
"os"
fingerly "github.com/fingerly-io/fingerly-go"
)
func main() {
client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))
event, err := client.Events.Get(context.Background(), "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")
if err != nil {
log.Fatal(err)
}
fmt.Println(event.VisitorID, event.SuspectLevel)
}
Output:
type ConnectionError ¶
type ConnectionError struct {
// Timeout reports whether a deadline passed.
Timeout bool
// contains filtered or unexported fields
}
ConnectionError reports a read that got no complete answer from the API: the connection was refused, reset or timed out, or the body broke off. Its message never carries the secret key.
errors.Is(err, fingerly.ErrTimeout) is true when Timeout is, and the cause stays reachable, so errors.Is(err, context.Canceled) still works.
func (*ConnectionError) Is ¶
func (e *ConnectionError) Is(target error) bool
Is matches ErrTimeout for a timed-out read.
type Event ¶
type Event struct {
RequestID string `json:"request_id"`
VisitorID string `json:"visitor_id"`
OccurredAt time.Time `json:"occurred_at"`
// State is "enriched", or "unavailable" when the network lookup could not run.
State string `json:"state"`
Environment string `json:"environment"`
// Platform is the scoring profile applied; SDKPlatform is what the SDK
// called itself. They differ: react-native-ios reports itself as such and
// is scored as ios.
Platform string `json:"platform"`
SDKPlatform string `json:"sdk_platform"`
SDKVersion string `json:"sdk_version"`
Origin string `json:"origin,omitempty"`
URLHost string `json:"url_host,omitempty"`
URLPath string `json:"url_path,omitempty"`
// Tag is your own reference, as the client sent it.
Tag string `json:"tag,omitempty"`
IPAddress string `json:"ip_address"`
CountryCode string `json:"country_code,omitempty"`
CountryName string `json:"country_name,omitempty"`
ASN string `json:"asn,omitempty"`
ASNName string `json:"asn_name,omitempty"`
// AnonymityNetwork is "tor", "vpn", "proxy" or "hosting". Empty on an
// ordinary consumer connection, which is most of them.
AnonymityNetwork string `json:"anonymity_network,omitempty"`
Agent string `json:"agent,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
OS string `json:"os,omitempty"`
DeviceKind string `json:"device_kind,omitempty"`
VisitorKind string `json:"visitor_kind,omitempty"`
// SuspectScore is nil when the request was not scored. Never read nil as 0.
SuspectScore *int `json:"suspect_score"`
// SuspectLevel is "low", "medium" or "high". Empty when not scored.
SuspectLevel string `json:"suspect_level,omitempty"`
// Triggers is never nil from the API: an event about which nothing fired
// carries an empty list.
Triggers []EventTrigger `json:"triggers"`
// HasDocument says whether the full submission was archived.
HasDocument bool `json:"has_document"`
// Document is the archived submission with the threshold and weights that
// scored it. Present when HasDocument is true and the archive could be
// read. Its contents are deliberately opaque, not a public contract.
Document json.RawMessage `json:"document,omitempty"`
// DeferredSignals is the deferred report attached to this request, when one
// arrived. Also opaque.
DeferredSignals json.RawMessage `json:"deferred_signals,omitempty"`
// Raw is the response body exactly as it arrived, so a field the API adds
// after this SDK was released is still reachable without an upgrade.
Raw json.RawMessage `json:"-"`
}
Event is a stored identification event.
SuspectScore is a pointer because nil means unscored, which must not be read as zero: an ordinary visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all.
type EventListParams ¶
type EventListParams struct {
// From and To bound the window. The default is the last 24 hours; it may
// be at most 30 days wide and start at most 30 days ago.
From time.Time
To time.Time
// Page is one-based. Limit is capped at 200 by the server.
Page int
Limit int
// Visitor restricts the listing to one exact visitor id.
Visitor string
// Level restricts it to "low", "medium" or "high".
Level string
}
EventListParams filters a listing. A zero field is not sent, so the server applies its own default.
type EventPage ¶
type EventPage struct {
Rows []Event `json:"rows"`
Page int `json:"page"`
PageSize int `json:"page_size"`
// Raw is the response body exactly as it arrived.
Raw json.RawMessage `json:"-"`
}
EventPage is one page of events, newest first.
type EventTrigger ¶
type EventTrigger struct {
// Signal is the signal group, such as "vpn" or "bot".
Signal string `json:"signal"`
// Weight is the group's combined weight in this score.
Weight int `json:"weight"`
// Confidence is "low", "medium" or "high".
Confidence string `json:"confidence,omitempty"`
}
EventTrigger is one signal group that contributed to an event's score.
type EventsService ¶
type EventsService struct {
// contains filtered or unexported fields
}
EventsService reads events stored for the organization and environment of the client's secret key. Reach it as client.Events.
func (EventsService) Format ¶
func (s EventsService) Format(f fmt.State, _ rune)
Format keeps the client's key out of every fmt verb; the service holds the client in an unexported field, which fmt prints without its methods.
func (*EventsService) Get ¶
Get fetches one event by request id.
An error matching ErrEventPending means the request id was accepted but the event is not recorded yet; APIError.RetryAfter says when to try again. One matching ErrEventNotFound means it was never issued or is past retention. Neither is retried.
An empty request id, "." or ".." returns ErrInvalidRequestID without sending anything.
func (*EventsService) List ¶
func (s *EventsService) List(ctx context.Context, params *EventListParams) (*EventPage, error)
List returns events within the retention window, newest first.
A nil params, or a zero field within it, leaves that filter unset so the server applies its own default.
Example ¶
A zero field is not sent, so the server applies its own default.
package main
import (
"context"
"fmt"
"log"
"os"
"time"
fingerly "github.com/fingerly-io/fingerly-go"
)
func main() {
client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))
page, err := client.Events.List(context.Background(), &fingerly.EventListParams{
Level: "high",
Limit: 50,
From: time.Now().Add(-24 * time.Hour),
})
if err != nil {
log.Fatal(err)
}
for _, event := range page.Rows {
fmt.Println(event.RequestID, event.IPAddress)
}
}
Output:
type Option ¶
type Option func(*clientConfig)
Option configures a Client. Pass options to New.
func WithAPIVersion ¶
WithAPIVersion selects a route version, such as "v2". The default is "v1", and the server must support whichever is chosen.
func WithEndpoint ¶
WithEndpoint overrides the default API origin, for a local or self-hosted API. The key still decides which kind of key is accepted.
func WithHTTPClient ¶
WithHTTPClient supplies the HTTP client, for a custom transport or for tests.
The client is copied and its CheckRedirect replaced, so a redirect can never replay the secret key to another host. Its Transport is used as given; a nil Transport means the SDK's shared connection pool rather than http.DefaultTransport, which keeps too few idle connections for a busy server.
func WithTimeout ¶
WithTimeout sets the deadline for each request, covering the response body. The default is 10 seconds. A deadline already on the caller's context still applies, whichever is sooner.
type Proxy ¶
type Proxy struct {
// contains filtered or unexported fields
}
Proxy serves the browser SDK from a path on your own domain, so content blockers cannot refuse its calls as third-party.
It implements http.Handler, so every router built on net/http takes it directly:
proxy, err := fingerly.NewProxy(fingerly.ProxyOptions{
ProxyKey: os.Getenv("FINGERLY_PROXY_KEY"),
Path: "/k7x2qm4a",
ClientIP: func(r *http.Request) string { return r.Header.Get("X-Real-IP") },
})
mux.Handle("/k7x2qm4a", proxy)
The browser SDK posts both of its calls to that one path, and the proxy forwards each to Fingerly's one ingest URL, never anywhere else and never to event reads: reading events needs a secret key and must stay unreachable from a browser. Customer cookies and authorization headers are never passed on, and nothing relayed back names Fingerly.
func NewProxy ¶
func NewProxy(options ProxyOptions) (*Proxy, error)
NewProxy validates every option and builds the handler.
Unlike New, this reports configuration errors immediately: a proxy is wired up once at start-up, where an error is natural to handle.
Example ¶
The proxy is an http.Handler, so every router takes it directly.
package main
import (
"log"
"net"
"net/http"
"os"
fingerly "github.com/fingerly-io/fingerly-go"
)
func main() {
proxy, err := fingerly.NewProxy(fingerly.ProxyOptions{
ProxyKey: os.Getenv("FINGERLY_PROXY_KEY"),
Path: "/k7x2qm4a",
ClientIP: func(r *http.Request) string {
// The header your load balancer or CDN sets. Trust it only when
// traffic can reach this server through that hop alone; never
// trust the left-most X-Forwarded-For value, which the visitor
// writes.
if address := r.Header.Get("X-Real-IP"); address != "" {
return address
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
},
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/k7x2qm4a", proxy)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Output:
type ProxyError ¶
type ProxyError struct {
// Status is what the browser was answered: 502, 504 or 500.
Status int
// Err is the underlying cause.
Err error
}
ProxyError is an upstream failure the proxy answered for. It never carries a key or a response body.
func (*ProxyError) Unwrap ¶
func (e *ProxyError) Unwrap() error
Unwrap returns the underlying cause.
type ProxyOptions ¶
type ProxyOptions struct {
// ProxyKey is a write-only credential, fly_px_…, held only on your server.
ProxyKey string
// SecretKey is the deprecated alternative to ProxyKey. Secret read keys
// will be refused in the next major version; do not pass both.
SecretKey string
// ClientIP resolves the visitor's address, which must be exactly one IPv4
// or IPv6 literal. Read it from a header your own infrastructure sets,
// such as your load balancer's or CDN's, and fall back to the TCP peer in
// r.RemoteAddr. Never use the left-most X-Forwarded-For value: the visitor
// writes it. Anything that is not one address is a configuration error:
// the proxy answers 500 without calling upstream and reports it once.
ClientIP func(*http.Request) string
// Path is the path the dashboard issued for this proxy, such as
// "/k7x2qm4a", matched exactly: any other path gets 404. Leave it empty
// when the route already sends only that path to the handler, and behind
// http.StripPrefix, which changes the path the handler sees.
Path string
// APIVersion selects the upstream ingest URL. Defaults to "v1".
APIVersion string
// Upstream overrides the API origin implied by the key.
Upstream string
// MaxBodyBytes refuses larger request bodies with 413. Defaults to 1 MiB.
MaxBodyBytes int64
// Timeout bounds the upstream call. Defaults to 5 seconds.
Timeout time.Duration
// OnError is called once for every request the proxy answered with a 5xx
// of its own: 504 when the upstream deadline passed, 502 for any other
// upstream failure, 500 for a ClientIP that returned no valid address.
// Leave it nil to log one line per failure through slog.Default(). A
// visitor who disconnects is not a failure and is not reported.
OnError func(*http.Request, *ProxyError)
// Transport supplies the upstream round tripper, for tests or a custom
// HTTP stack. Left nil, the SDK's shared connection pool is used, sized
// for hundreds of requests in flight. Redirects are never followed either
// way.
Transport http.RoundTripper
}
ProxyOptions configures a Proxy. ProxyKey and ClientIP are required.
func (ProxyOptions) Format ¶
func (o ProxyOptions) Format(f fmt.State, _ rune)
Format keeps the keys out of every fmt verb, so logging the options a proxy was built with is safe.
func (ProxyOptions) GobEncode ¶
func (o ProxyOptions) GobEncode() ([]byte, error)
GobEncode refuses to serialise the options: gob skips the callbacks but would copy the keys in the clear.
func (ProxyOptions) MarshalJSON ¶
func (o ProxyOptions) MarshalJSON() ([]byte, error)
MarshalJSON keeps the keys out of JSON, and leaves out the callbacks, which cannot be encoded.
type WebhookEvent ¶
type WebhookEvent struct {
// ID identifies this delivery. Persist it to deduplicate: a webhook may
// arrive more than once.
ID string `json:"id"`
// Type is the event name, such as "identification.completed".
Type string `json:"type"`
// Version is the envelope version.
Version int `json:"version"`
// OrganizationID is the organization the event belongs to.
OrganizationID string `json:"organization_id"`
// Environment is the deployment the event came from.
Environment string `json:"environment"`
// CreatedAt is when Fingerly recorded the event.
CreatedAt time.Time `json:"created_at"`
// Data is the event-specific payload.
Data json.RawMessage `json:"data"`
}
WebhookEvent is the envelope every webhook delivery shares.
Data holds the event-specific payload, left raw so a new event type needs no SDK upgrade. Unmarshal it into the shape the event documents.
type WebhookOption ¶
type WebhookOption func(*webhookConfig)
WebhookOption configures VerifyWebhook.
func WithWebhookTime ¶
func WithWebhookTime(now time.Time) WebhookOption
WithWebhookTime fixes the current time. Override it only in tests.
func WithWebhookTolerance ¶
func WithWebhookTolerance(tolerance time.Duration) WebhookOption
WithWebhookTolerance sets the replay window. The default is five minutes.