fingerly-go reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. It has no dependencies outside the standard library.
Requirements
- Go 1.22 or newer.
- A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.
Install
go get github.com/fingerly-io/fingerly-go
Read an event
Create one client with your secret key and reuse it. The key names the environment the client reads.
import fingerly "github.com/fingerly-io/fingerly-go"
client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))
event, err := client.Events.Get(ctx, "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")
An event has the fields listed in Get an event. suspect_score is null when the request was not scored.
Verify a checkout
Read the event your client identified, check it belongs to this action and is recent, then decide on its level. See server-side verification.
func decide(ctx context.Context, orderID, requestID string) (string, error) {
event, err := client.Events.Get(ctx, requestID)
var apiErr *fingerly.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
return "refuse", nil
} else if err != nil {
return "", err
}
if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute {
return "refuse", nil
}
switch event.SuspectLevel {
case "high":
return "review", nil
case "medium":
return "challenge", nil
}
return "allow", nil
}
Verify a webhook
Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now.
func fingerlyWebhook(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
_ = json.Unmarshal(body, &event)
enqueue(event.ID, body) // deduplicate on the event ID
w.WriteHeader(http.StatusNoContent)
}
Deadlines and cancellation
Every call takes a context.Context, and a Go request context governs the whole exchange, so the deadline covers reading the response body too. The client applies its own timeout (10 seconds by default) on top of whatever the caller's context already carries, whichever is sooner.
Unscored events
Event.SuspectScore is a *int, because nil means unscored and must never be read as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. Scored() and Score() put that distinction at the call site.
score, ok := event.Score()
if !ok {
// Unscored. Decide on something else; do not treat it as clean.
}
Event.Raw and EventPage.Raw keep the response body exactly as it arrived, so a field the API adds later is reachable without an SDK upgrade.
Read an event you just identified
An event is readable as soon as identification answers, so this needs nothing special.
event, err := client.Events.Get(ctx, requestID)
Earlier versions had a WaitFor that polled through the gap between identification and the event becoming queryable. There is no gap, so ErrEventNotFound now means a request id that was never issued. The archived document arrives moments after the event itself, so check HasDocument rather than assuming it is there.
Serve the browser SDK from your domain
Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy at the path the dashboard issued for it, in Integration > Proxy keys, and those requests become first-party. See proxy integrations.
Proxy implements http.Handler, so every Go router takes it directly. Unlike New, NewProxy reports configuration errors immediately: a proxy is wired up once at start-up, where an error is natural to handle.
proxy, err := fingerly.NewProxy(fingerly.ProxyOptions{
ProxyKey: os.Getenv("FINGERLY_PROXY_KEY"), // fly_px_production_…
Path: "/k7x2qm4a",
ClientIP: func(r *http.Request) string { return r.Header.Get("CF-Connecting-IP") },
})
if err != nil {
log.Fatal(err)
}
mux.Handle("/k7x2qm4a", proxy) // net/http
router.Handle("/k7x2qm4a", proxy) // gorilla/mux
r.Handle("/k7x2qm4a", proxy) // chi
router.Any("/k7x2qm4a", gin.WrapH(proxy)) // gin
With Path set, the proxy answers 404 to any other path. It answers 405 to anything but POST, and 401 when the request carries no public key for the same environment as the proxy key. Every request is forwarded to https://api.fingerly.io/api/v1/ingest, and only content-type, x-request-id and the rate limit headers are relayed back, so nothing on the response names Fingerly. Event reads need a secret key and are unreachable through it. Responses are marked no-store, customer cookies and authorization headers are never forwarded, and the proxy reports itself upstream as go/0.1.0, which the dashboard shows per proxy.
API
fingerly.New(secretKey, opts...)
secretKeystringrequiredA secret key. Its prefix names the environment read.WithEndpointstringOverride the API origin.WithAPIVersionstringDefault"v1"API route version; the server must support the selected version.WithTimeouttime.DurationDefault10sDeadline per request, covering the response body.WithHTTPClient*http.ClientA custom client. It is copied and its CheckRedirect replaced, so a redirect can never replay the key.
New returns only a client, so it composes in a package-level var. An invalid key or option is reported by the first call, before anything is sent.
| Method | Returns | Calls |
|---|---|---|
Events.Get(ctx, requestID) | (*Event, error) | GET /events/{request_id}. |
Events.List(ctx, params) | (*EventPage, error) | GET /events. params may be nil; a zero field is not sent, so the server defaults it. |
VerifyWebhook(secret, payload, timestamp, signature, opts...) | bool | Never panics for malformed input. WithWebhookTolerance sets the replay window, five minutes by default. |
NewProxy(options) | (*Proxy, error) | An http.Handler for the browser SDK's submissions, never event reads. ProxyOptions.Path is the issued path. |
A non-2xx response returns an *APIError with Status, and optional Code, Details and RequestID; the secret key is stripped from all three. Match it with errors.As, or use errors.Is(err, fingerly.ErrEventNotFound) for the one retryable read failure.