Guide

Ruby

A Rails controller using Net::HTTP from the standard library. No extra gems.

Browser

<script src="https://nolgard.com/v1/client.js" data-site-key="SITE_KEY" defer></script>
<script>
  document.querySelector('#signup').addEventListener('submit', async (event) => {
    event.preventDefault();
    let token; // stays undefined when the script is blocked: the server takes the missing-token path
    try {
      await Nolgard.ready;
      token = await Nolgard.execute({ action: 'signup' });
    } catch {}
    const form = event.target;
    const response = await fetch('/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token, email: form.elements.email.value })
    });
    // Show your own success or error message here.
  });
</script>

Rails

# app/controllers/signups_controller.rb
# config/routes.rb: post "/signup", to: "signups#create"
require "json"
require "net/http"

class SignupsController < ApplicationController
  VERIFY_URL = URI("https://nolgard.com/v1/verify")
  UNAVAILABLE = { "success" => false, "error" => "unavailable" }.freeze

  def create
    email = params[:email].to_s
    result = nolgard_verify(params[:token].to_s, "signup", { email: email })

    unless result["success"]
      case result["error"]
      when "invalid-secret", "invalid-input"
        return render json: { error: "try-again" }, status: 503 # config error: fail closed
      when "missing-token" # script blocked: not a bot verdict
        create_account(email, trial_days: 3, confirm_email_first: true)
        return render json: { ok: true, confirm_email: true }
      when "unavailable", "over-quota" # fail open with a reduced grant
        create_account(email, trial_days: 3)
        return render json: { ok: true }
      else
        return render json: { error: "retry-token" }, status: 409 # stale token: the form retries once
      end
    end

    case result["decision"]
    when "allow"
      create_account(email)
    when "limit"
      create_account(email, trial_days: 3)
    when "shadow"
      # same response, nothing created
    when "challenge"
      return render json: { error: "confirm-email" }, status: 403
    else
      return render json: { error: "rejected" }, status: 403
    end
    render json: { ok: true }
  end

  private

  def nolgard_verify(token, action, account = nil)
    payload = {
      secret: ENV.fetch("NOLGARD_SECRET"),
      token: token,
      remoteIp: request.remote_ip,
      action: action
    }
    payload[:account] = account if account
    begin
      response = Net::HTTP.start(VERIFY_URL.host, VERIFY_URL.port, use_ssl: true,
                                 open_timeout: 3, read_timeout: 3, write_timeout: 3) do |http|
        http.post(VERIFY_URL.path, payload.to_json, "Content-Type" => "application/json")
      end
      return UNAVAILABLE if response.code.to_i >= 500
      JSON.parse(response.body)
    rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError,
           Net::HTTPBadResponse, JSON::ParserError
      UNAVAILABLE # timeout, network error, 5xx or bad JSON
    end
  end
end

Supply your own private create_account method; confirm_email_first holds the trial until the user clicks your confirmation link. Without a token (ad blocker, Brave Shields, no JavaScript) the user goes through email confirmation instead of a rejection. Keep Rails' CSRF protection on: send the token from csrf_meta_tags in an X-CSRF-Token header with the form's fetch. request.remote_ip skips the proxies listed in config.action_dispatch.trusted_proxies; add your own proxy there. Do not trust unchecked forwarding headers.

Sinatra

The same code works in a Sinatra post "/signup" route. Put nolgard_verify in a helpers block, read the body with JSON.parse(request.body.read), use request.ip for remoteIp, and replace each render json: with halt status, body.to_json.