Docs

Documentation

Quick start

Create an account, add a site and list its origins. Use the site key on the page and keep the secret on your backend.

1. Get a token in the 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();
    await Nolgard.ready;
    const token = await Nolgard.execute({ action: 'signup' });
    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>

execute() returns an opaque token. It carries no score; the decision comes back from the verify call on your server. Get a new token on every submit. Tokens are single-use and expire after 5 minutes.

2. Verify it on your backend

const response = await fetch('https://nolgard.com/v1/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    secret: process.env.NOLGARD_SECRET,
    token,
    remoteIp,            // the visitor's IP as your server sees it
    action: 'signup',
    account: { email }   // optional, see Account signals
  })
});
const result = await response.json();

if (!result.success) {
  if (result.error === 'invalid-secret') return verificationUnavailable(); // config: fail closed
  if (result.error === 'over-quota') return createAccount();               // fail open
  return reject();                                                         // bad token
}
switch (result.decision) {
  case 'allow':
  case 'limit':     return createAccountWithTrial(result.grant.credits ?? result.grant.share);
  case 'challenge': return askForYourOwnStepUp();
  case 'shadow':    return pretendSuccess();   // no account, no email, no credits
  case 'block':     return reject();
  default:          return reject();
}

Keep the secret on the server. For a runnable example, see the Node.js guide.

Analytics gating

Nolgard.gate(() => {
  loadAnalytics(); // Plausible, PostHog, Google Analytics, ...
});

If client hints are enabled for the site, the callback runs only for visitors judged human. Otherwise, it runs after any successful check. Use this for analytics filtering, never access control.

Integrations

More

Other platforms

Account signals

Send details of the account your backend is about to create or log in to:

{ "secret": "SITE_SECRET", "token": "...", "action": "signup",
  "account": { "id": "user_123", "email": "jane+trial@example.com" } }

Both fields are optional. Use a stable ID for each real account. Sending these fields can add the following to the response:

  • repeat.deviceAccounts: distinct accounts seen from this browser recently.
  • repeat.networkAccounts: distinct accounts seen from this network recently.
  • repeat.emailAliases: different spellings of the same mailbox, such as plus addresses.
  • email.domain and email.disposable: the domain and whether it provides throwaway addresses.

Shadow and block modes give repeat accounts limit. Use the counts for your own rules, such as one free trial per mailbox. Set the repeat threshold and cost per abused event in site settings.

Account IDs and emails are stored as salted hashes only. Raw emails are not stored.

Verify API

POST https://nolgard.com/v1/verify with a JSON body. Call it from your backend only.

CI test keys and the POST /v1/feedback API: Test keys and feedback.

Request

FieldTypeNotes
secretstringSite secret. Backend only.
tokenstringFrom Nolgard.execute().
remoteIpstring, optionalVisitor IP from your connection or a proxy you trust. Never from form data.
actionstring, optionalExpected action. A mismatch fails with action-mismatch.
account{id?, email?}, optionalYour account ID and email. See Account signals.
headers, authorityoptionalOriginal inbound headers and Host, for AI agents.

Response

{ "success": true, "verdict": "human", "score": 0, "decision": "allow",
  "categories": [], "scores": { "automation": 0, "repeat": 0, "abuse": 0 }, "ref": "ev_8f2c1a", "action": "signup", "tokenAgeMs": 1840 }
FieldTypeNotes
successbooleanToken was valid, unexpired, unused and matched the site and action. A valid token does not mean the visitor is human.
verdicthuman | suspicious | bot
scores{ automation, repeat, abuse }Integers 0–100. Bot signals, links to earlier accounts, and email/usage/referral/promo abuse, scored separately. See Risk axes and tuning.
score0..100Higher means more likely automated.
decisionallow | limit | challenge | shadow | blockWhat to do, based on the site mode.
categoriesstring[]Empty for clean humans. Values: automation, headless, script, spoofed, proxy, behavior, abuse, crawler, agent.
refstringEvent reference. Quote it when you contact support.
actionstring | nullAction the token was created for.
tokenAgeMsnumberToken age at verification.
quotaExceededtrue, optionalPresent once you are over the monthly quota.
testtrue, optionalPresent only for test keys. Never accept it in production.
repeatobject, optionalSee Account signals.
email{domain, disposable}, optionalPresent when you send account.email.
agentobject, optionalSee AI agents.
wouldDecidedecision, optionalObserve mode only: what block mode would have returned.

Trial grants

Every verify response includes grant: { share, credits, requires }. Use grant.credits ?? grant.share as the trial budget. Share is 0..1. Credits is null until you set full trial credits in Settings → Trial; once set, it is floor(share × credits).

{ "grant": { "share": 0.2, "credits": 20, "requires": null } }

Allow grants 100%. Limit grants the configured share (default 20%). Challenge, shadow, block and failures grant zero. Challenge requires step-up by default, payment if configured, or null if configured to block. Other decisions require nothing. Observe grants the full trial and includes wouldGrant from wouldDecide. Successful step-up verification returns its own grant: limited by default, or full if configured.

PATCH /api/sites/:key with { "trial": { "credits": 100, "limitShare": 0.2, "challenge": "step-up", "afterStepUp": "limited" } }. Credits: integer 0..1000000000 or null; limitShare: 0..1; challenge: step-up/payment/block; afterStepUp: full/limited.

Decisions & modes

DecisionYour backend
allowComplete the requested action.
limitReduce access, such as a shorter trial, fewer credits or no signup bonus.
challengeAsk for your own step-up, such as email confirmation or SMS. Nolgard has no CAPTCHA.
shadowReturn your normal success response, but create nothing and send nothing.
blockReject with a generic error.

Verdict × mode

VerdictObserveShadowBlock
humanallowallowallow
suspiciousallowallowchallenge
botallowshadowblock
repeat account that is not a botallowlimitlimit

Observe mode returns the block-mode decision in wouldDecide. A suspicious repeat account in block mode gets challenge.

Set each site's mode in the portal. Roll out in this order:

  • Observe. Always returns allow. Check wouldDecide for the block-mode decision.
  • Shadow. Returns shadow for bots and limit for repeat accounts.
  • Block. Returns block for bots, limit for repeat accounts and challenge for borderline visitors.

Your backend must enforce shadow decisions. Nolgard cannot stop your app from sending a welcome email. Mode, origin and client-hint changes reach browsers within 15 seconds. Secret rotation is immediate.

Handling suspicious visitors

  • At signup, create the account with a smaller trial or no bonus.
  • Require email confirmation before issuing credits.
  • Hold payouts and referral rewards for review.
  • Do not tell the visitor why.

Start with 2 weeks in Observe. Watch wouldDecide and the portal. Label a few events, then switch to Shadow or Block.

Actions

Pass the same action to execute() and /v1/verify.

ActionAdvice
signupSend account.email. Use limit for trial size and credits.
loginSend account.id. On challenge, ask for your own second factor.
redeem_promoSend account.id. On limit, skip the bonus.
checkoutCall it before creating the Stripe session. Adds payment: { hold, threeDS }: Payments.

When verify fails

On signup, fail open for timeouts and 5xx so real users can get in. For config errors (invalid-secret), fail closed and alert yourself.

let result;
try {
  const response = await fetch('https://nolgard.com/v1/verify', { method: 'POST', headers, body, signal: AbortSignal.timeout(3000) });
  if (response.status >= 500) return createAccount(); // 5xx: fail open
  result = await response.json();
} catch {
  return createAccount(); // timeout or network: fail open
}
if (result.error === 'invalid-secret') {
  alertOnCall('Nolgard secret is wrong');
  return verificationUnavailable(); // config error: fail closed
}

AI agents

A user agent string alone does not verify an agent. Nolgard checks Web Bot Auth signatures or the operator's official IP ranges. A verified agent response looks like this:

"agent": { "name": "ChatGPT-User", "operator": "openai.com", "verified": true, "method": "web-bot-auth" }

method is web-bot-auth, ip-range or null. Set a policy for verified agents on each site: allow, limit, shadow or block (the default). Unverified agents are treated like other automated visitors.

Signed requests to your own app

To check signatures sent to your server, forward the original inbound request headers and Host from your backend. Forward the headers you received; the headers your own outbound call sends are the wrong ones. Never take these values from browser JSON.

{ "secret": "SITE_SECRET", "token": "...", "remoteIp": "203.0.113.42",
  "authority": "app.example.com",
  "headers": {
    "user-agent": "...",
    "signature-agent": "...",
    "signature-input": "...",
    "signature": "..."
  } }

Alerts

Under the site's integrations, add a webhook, Slack, Telegram or email destination. Set the bot-share percentage and minimum event count. Checks run hourly. Each destination gets at most one alert every six hours. Email alerts go to your confirmed address.

{
  "site": "pk_example",
  "botShare": 75,
  "total": 100,
  "bot": 75,
  "hour": "2026-10-21T10",
  "url": "https://nolgard.com/app/#/sites/pk_example"
}

The example above is the webhook body. hour is UTC. Slack receives a text message. Telegram receives chat_id and text. The test button sends zero counts.

Quotas

Free includes 1,000 new accounts per UTC calendar month across all your sites, no card and no time limit. A successful verify counts once when its account hash is first seen on that site. Without an account, every successful verify counts. Repeat logins, usage events, step-ups and failed verifies are not metered.

Pro and Business open when billing ships; these are the prices we intend to charge. Pro $49/month for 10,000 new accounts, then $0.006 each. Business $199/month for 50,000, then $0.004 each. Enterprise is custom above 100,000. If you expect more than 1,000 a month, ask for a 60 day pilot at no cost or request access.

At 100%, verification keeps working and adds quotaExceeded: true. At 200%, it returns HTTP 429 over-quota and leaves the token unused. Usage counts can lag by up to 30 seconds.

If verification is over quota or unreachable, let signups through unchecked (fail open). If the secret is wrong, stop and fix it (fail closed). See When verify fails.

Errors

Error responses use {"success": false, "error": "..."}.

ErrorMeaningWhat to do
invalid-inputMalformed request body.Fix your request.
invalid-secretHTTP 401. Wrong secret.Fail closed and fix the configuration.
invalid-tokenToken was not issued by Nolgard.Reject.
expiredOlder than 5 minutes.Reject. Fetch a new token in the browser on submit.
replayedToken was already used.Reject.
site-mismatchToken belongs to another site.Reject.
action-mismatchToken action differs from action.Reject. The token is consumed.
over-quotaHTTP 429. Twice the monthly quota used.Apply your fail-open or fail-closed policy.

Requests with a wrong secret are not logged or counted. Other failed verifications are logged but do not count toward usage.

Privacy

Nolgard checks network, browser and behaviour context on its servers. Raw IPs are not stored. Network identifiers are salted hashes. Events are deleted after 30 days. Data is stored in Cloudflare's EU jurisdiction.

If your jurisdiction requires consent before analytics, collect it before calling gate(). Do not put emails or other personal data in action names or URLs.

Nolgard will miss some bots and flag some people. Give real users a way through, such as a support contact or a challenge step.

Privacy policy · Terms of service · privacy@nolgard.com