Guide

Rust

An axum handler using reqwest and serde_json.

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>

axum

// src/main.rs
use std::{net::SocketAddr, sync::LazyLock, time::Duration};

use axum::{Json, Router, extract::ConnectInfo, http::StatusCode, routing::post};
use serde::Deserialize;
use serde_json::{Value, json};

static NOLGARD: LazyLock<reqwest::Client> = LazyLock::new(|| {
    reqwest::Client::builder()
        .timeout(Duration::from_secs(3))
        .build()
        .expect("http client")
});

// None means timeout, network error, 5xx or an unreadable body.
async fn nolgard_verify(
    token: &str,
    remote_ip: &str,
    action: &str,
    account: Option<Value>,
) -> Option<Value> {
    let mut payload = json!({
        "secret": std::env::var("NOLGARD_SECRET").unwrap_or_default(),
        "token": token,
        "remoteIp": remote_ip,
        "action": action,
    });
    if let Some(account) = account {
        payload["account"] = account;
    }
    let response = NOLGARD
        .post("https://nolgard.com/v1/verify")
        .json(&payload)
        .send()
        .await
        .ok()?;
    if response.status().is_server_error() {
        return None;
    }
    response.json().await.ok()
}

#[derive(Default)]
struct AccountOptions {
    trial_days: u32,           // 0 means your normal plan
    confirm_email_first: bool, // hold the trial until the email is confirmed
}

#[derive(Deserialize)]
struct SignupForm {
    #[serde(default)]
    token: String,
    #[serde(default)]
    email: String,
}

async fn signup(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    Json(form): Json<SignupForm>,
) -> (StatusCode, Json<Value>) {
    let email = &form.email;
    let result = nolgard_verify(
        &form.token,
        &addr.ip().to_string(),
        "signup",
        Some(json!({ "email": email })),
    )
    .await
    .unwrap_or_else(|| json!({ "success": false, "error": "unavailable" })); // timeout or 5xx

    if result["success"] != true {
        return match result["error"].as_str().unwrap_or_default() {
            "invalid-secret" | "invalid-input" => {
                (
                    StatusCode::SERVICE_UNAVAILABLE,
                    Json(json!({ "error": "try-again" })),
                ) // config error: fail closed
            }
            "missing-token" => {
                // script blocked: not a bot verdict
                create_account(
                    email,
                    AccountOptions {
                        trial_days: 3,
                        confirm_email_first: true,
                    },
                );
                (
                    StatusCode::OK,
                    Json(json!({ "ok": true, "confirm_email": true })),
                )
            }
            "unavailable" | "over-quota" => {
                // fail open with a reduced grant
                create_account(
                    email,
                    AccountOptions {
                        trial_days: 3,
                        ..Default::default()
                    },
                );
                (StatusCode::OK, Json(json!({ "ok": true })))
            }
            _ => (
                StatusCode::CONFLICT,
                Json(json!({ "error": "retry-token" })),
            ), // stale token: the form retries once
        };
    }

    match result["decision"].as_str().unwrap_or_default() {
        "allow" => create_account(email, AccountOptions::default()),
        "limit" => create_account(
            email,
            AccountOptions {
                trial_days: 3,
                ..Default::default()
            },
        ),
        "shadow" => {} // same response, nothing created
        "challenge" => {
            return (
                StatusCode::FORBIDDEN,
                Json(json!({ "error": "confirm-email" })),
            );
        }
        _ => return (StatusCode::FORBIDDEN, Json(json!({ "error": "rejected" }))),
    }
    (StatusCode::OK, Json(json!({ "ok": true })))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/signup", post(signup));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(
        listener,
        app.into_make_service_with_connect_info::<SocketAddr>(),
    )
    .await
    .unwrap();
}

Dependencies: axum 0.8, tokio with the full feature, reqwest 0.13 with the json feature, serde with derive, and serde_json. Needs Rust 1.80 or later for LazyLock. Supply your own create_account function; 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. ConnectInfo is the direct peer. Behind a proxy, use the address set by your trusted proxy. Do not trust unchecked forwarding headers.