Guide

Go

A net/http handler using only the standard library. The same function works in chi and gin.

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>

net/http

// main.go
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"net"
	"net/http"
	"os"
	"time"
)

var nolgardClient = &http.Client{Timeout: 3 * time.Second}

type verifyResult struct {
	Success  bool   `json:"success"`
	Decision string `json:"decision"`
	Error    string `json:"error"`
}

func nolgardVerify(r *http.Request, token, action string, account map[string]string) (verifyResult, error) {
	remoteIP, _, _ := net.SplitHostPort(r.RemoteAddr)
	payload := map[string]any{
		"secret":   os.Getenv("NOLGARD_SECRET"),
		"token":    token,
		"remoteIp": remoteIP,
		"action":   action,
	}
	if account != nil {
		payload["account"] = account
	}
	body, err := json.Marshal(payload)
	if err != nil {
		return verifyResult{}, err
	}
	resp, err := nolgardClient.Post("https://nolgard.com/v1/verify", "application/json", bytes.NewReader(body))
	if err != nil {
		return verifyResult{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 500 {
		return verifyResult{}, errors.New(resp.Status)
	}
	var result verifyResult
	err = json.NewDecoder(resp.Body).Decode(&result)
	return result, err
}

type AccountOptions struct {
	TrialDays         int  // 0 means your normal plan
	ConfirmEmailFirst bool // hold the trial until the email is confirmed
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func signup(w http.ResponseWriter, r *http.Request) {
	var data struct {
		Token string `json:"token"`
		Email string `json:"email"`
	}
	json.NewDecoder(r.Body).Decode(&data) // a bad body leaves the token empty: missing-token path
	result, err := nolgardVerify(r, data.Token, "signup", map[string]string{"email": data.Email})
	if err != nil {
		result = verifyResult{Error: "unavailable"} // timeout or 5xx
	}

	if !result.Success {
		switch result.Error {
		case "invalid-secret", "invalid-input":
			writeJSON(w, 503, map[string]string{"error": "try-again"}) // config error: fail closed
		case "missing-token": // script blocked: not a bot verdict
			createAccount(data.Email, AccountOptions{TrialDays: 3, ConfirmEmailFirst: true})
			writeJSON(w, 200, map[string]bool{"ok": true, "confirm_email": true})
		case "unavailable", "over-quota": // fail open with a reduced grant
			createAccount(data.Email, AccountOptions{TrialDays: 3})
			writeJSON(w, 200, map[string]bool{"ok": true})
		default:
			writeJSON(w, 409, map[string]string{"error": "retry-token"}) // stale token: the form retries once
		}
		return
	}

	switch result.Decision {
	case "allow":
		createAccount(data.Email, AccountOptions{})
	case "limit":
		createAccount(data.Email, AccountOptions{TrialDays: 3})
	case "shadow":
		// same response, nothing created
	case "challenge":
		writeJSON(w, 403, map[string]string{"error": "confirm-email"})
		return
	default:
		writeJSON(w, 403, map[string]string{"error": "rejected"})
		return
	}
	writeJSON(w, 200, map[string]bool{"ok": true})
}

func main() {
	http.HandleFunc("POST /signup", signup)
	http.ListenAndServe(":8080", nil)
}

Needs Go 1.22 or later for the POST /signup pattern. Supply your own createAccount function; ConfirmEmailFirst 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. The same signup function works in chi with r.Post("/signup", signup) and in gin with router.POST("/signup", gin.WrapF(signup)). r.RemoteAddr is the direct peer. Behind a proxy, use the address set by your trusted proxy. Do not trust unchecked forwarding headers.