SDKs

Ruby

Read identification events, verify signed webhooks, and serve the browser SDK from your own domain on Ruby 3.2 and newer, with Rack middleware and RBS signatures.

fingerly reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain as Rack middleware. It has no runtime dependencies.

Requirements

  • Ruby 3.2 or newer. Everything the gem needs is in the standard library.
  • Rack for the proxy, which your application server already provides. Rack 2 and Rack 3 both work.
  • A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.

Install

gem "fingerly", "~> 0.1"

Read an event

Create one client with your secret key and reuse it. The key names the environment the client reads.

Ruby
require "fingerly"

fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY"))
event = fingerly.events.get("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.

Ruby
def decide(order_id, request_id)
  event = fingerly.events.get(request_id)

  return "refuse" unless event.tag == "checkout:#{order_id}"
  return "refuse" if event.occurred_at < Time.now - 120

  case event.suspect_level
  when "high" then "review"
  when "medium" then "challenge"
  else "allow"
  end
rescue Fingerly::APIError => e
  raise unless e.status == 404
  "refuse"
end

Verify a webhook

Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now.

Ruby
class FingerlyWebhooksController < ActionController::API
  def create
    # raw_post is the exact bytes; a body rebuilt from params is not.
    payload = request.raw_post

    valid = Fingerly::Webhooks.verify(
      secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"),
      payload: payload,
      timestamp: request.headers["x-fingerly-timestamp"],
      signature: request.headers["x-fingerly-signature"],
    )
    return head :bad_request unless valid

    event = Fingerly::Webhooks.parse(payload)
    FingerlyEventJob.perform_later(event.id, payload)   # deduplicate on the ID
    head :no_content
  end
end

One client, not two

There is no asynchronous client to go with the synchronous one, and none is needed. Ruby has one server interface, Rack, and every framework on it calls your application synchronously. The stack that is not thread-per-request is covered anyway: Net::HTTP honours the fiber scheduler on every Ruby this gem supports, so under Falcon or the async gem the blocking reads in this gem yield to the scheduler rather than holding the thread. That is the reason the floor is 3.2: the gem's own test asserts the overlap, and it does not hold on 3.1.

Deadlines

timeout: bounds the whole exchange, including receiving the body. That takes care on this platform: Net::HTTP has open_timeout, read_timeout and write_timeout, but each bounds a single operation, so a server that answers and then sends one byte a second would satisfy all three forever. The client tracks a deadline across the exchange and shrinks the socket's own read timeout to whatever is left of it before each chunk.

Unscored events

event.suspect_score is nil when the request was not scored. Never read nil as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. event.scored? states the distinction at the call site, and event.raw keeps the response body so a field the API adds later needs no gem upgrade.

Read an event you just identified

An event is readable as soon as identification answers, so this needs nothing special.

read.rb
event = fingerly.events.get(request_id)

Earlier versions had a wait_for that polled through the gap between identification and the event becoming queryable. There is no gap, so event_not_found? now means a request id that was never issued. The archived document arrives moments after the event itself, so check has_document? 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.

Rack is the one interface every Ruby server and framework shares, so there is one middleware and no per-framework variants.

# config/application.rb
config.middleware.use Fingerly::Proxy,
  proxy_key: ENV.fetch("FINGERLY_PROXY_KEY"),   # fly_px_production_…
  path: "/k7x2qm4a",
  client_ip: ->(request) { request.header("CF-Connecting-IP") }

Put it early, ahead of authentication: the browser SDK sends no cookies and carries no user, and a filter that redirected it to a sign-in page would break identification for every signed-out visitor.

Every other path is passed on to your application. 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 ruby/0.1.0, which the dashboard shows per proxy.

API

Fingerly::Client.new

  • secret_keyStringrequired
    A secret key. Its prefix names the environment read.
  • endpointString
    Override the API origin.
  • api_versionStringDefault "v1"
    API route version; the server must support the selected version.
  • timeoutNumericDefault 10
    Deadline per request in seconds, covering the response body.
  • configure_httpProc
    Receives the Net::HTTP before it connects, for a proxy or a custom TLS setup.
MethodReturnsCalls
events.get(request_id)EventGET /events/{request_id}.
events.list(**filters)EventPageGET /events. An unset filter is not sent, so the server defaults it. The page is Enumerable.
Fingerly::Webhooks.verify(secret:, payload:, timestamp:, signature:)true/falseOver the raw bytes. Never raises for malformed input; a five-minute replay window by default.
Fingerly::Webhooks.parse(payload)WebhookEventThe delivery envelope. data stays a plain Hash.
Fingerly::ProxyRack middlewareThe same-origin proxy, mounted with use or config.middleware.use.

Every runtime failure derives from Fingerly::Error. A non-2xx response raises Fingerly::APIError with status, and optional code, details and request_id; the secret key is stripped from all three. A deadline raises Fingerly::TimeoutError and an unreachable API raises Fingerly::ConnectionError. A bad option raises Fingerly::ConfigurationError, which is an ArgumentError, because a key that was never valid is a programming mistake rather than something to rescue around a call.