Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.agg.market/llms.txt

Use this file to discover all available pages before exploring further.

Bot protection is enabled by default on every new app. A Cloudflare Turnstile widget is provisioned automatically at app-creation time (provided the app has at least one allowedOrigin), so you can start using it immediately — no setup required. This guide covers the full protection model, the standalone /bot-protection/* endpoints you can use to gate any action in your product, and how to render the Turnstile widget in your frontend.

Overview

AGG ships a four-layer protection model. As an integrator, you only need to handle two response shapes in your client:
  • 429 Too Many Requests — back off and retry after the server-provided delay.
  • challenge_required — render a Turnstile widget, collect the token, and resubmit.
LayerPurposeClient-visible outcome
1Per-IP rate limit across every endpoint429 Too Many Requests
2Per-app rate limit across every endpoint429 Too Many Requests
3Tighter rate limit on POST /auth/start429 Too Many Requests
4Cloudflare Turnstile challenge on sign-inchallenge_required403 on invalid token
Layers 1–3 are automatic: they run before your request reaches any handler and return a standard 429 you can retry. Layer 4 is the Turnstile challenge, which you integrate once and reuse anywhere in your product.

Rate limits

Layer 1 — Per-IP limit

PropertyValue
ScopeAll endpoints
Default100 requests / 60 seconds per IP
Response429 Too Many Requests with Retry-After header (seconds)
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json

{ "message": "Too many requests. Retry after 37s." }

Layer 2 — Per-app limit

PropertyValue
ScopeAll endpoints that carry x-app-id
Default500 requests / 60 seconds per app
Response429 Too Many Requests with Retry-After header
Layer 2 is sized for aggregate traffic across your entire user base, so it comfortably accommodates normal usage patterns.

Layer 3 — Sign-in limit

A tighter per-IP limit applied only to POST /auth/start. Authentication endpoints are high-value targets for credential stuffing, nonce mining, and email enumeration, so they warrant stricter throttling.
PropertyValue
ScopePOST /auth/start only
Default20 requests / 60 seconds per IP
Response429 Too Many Requests with retryAfter in the response body
HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{
  "message": "Too many requests. Retry after 12s.",
  "retryAfter": 12
}

Handling 429 on the client

Always honor the server’s retry hint: Retry-After header for Layers 1 and 2, retryAfter body field for Layer 3. Never hardcode a fixed delay.
async function authStartWithBackoff(
  body: AuthStartBody,
  attempt = 0,
): Promise<AuthStartResponse> {
  try {
    return await client.authStart(body);
  } catch (err) {
    if (isRateLimitError(err) && attempt < 3) {
      const waitMs = (err.retryAfter ?? 2 ** attempt) * 1000;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      return authStartWithBackoff(body, attempt + 1);
    }
    throw err;
  }
}
A burst of 429 responses during normal use typically indicates a tight retry loop in your client. Debounce the caller and surface a visible “please wait” state after the second consecutive 429.

Turnstile challenges

Layer 4 activates when all of the following are true:
  1. The sign-in provider is siwe, siws, or email (OAuth providers are exempt).
  2. The app has botProtectionEnabled = true.
  3. The app’s registered user count is at or above botProtectionThreshold.
  4. A Turnstile widget is linked to the app.
When activated, POST /auth/start returns { type: "challenge_required", siteKey } instead of the usual nonce or magic link. Your client renders the Turnstile widget, collects the resulting token, and retries /auth/start with turnstileToken included in the body.

Example — SIWE with inline challenge handling

async function signInWithSiwe(
  address: string,
  signMessage: (msg: string) => Promise<string>,
) {
  let start = await client.authStart({ provider: "siwe" });

  if (start.type === "challenge_required") {
    const turnstileToken = await renderTurnstileWidget({ siteKey: start.siteKey });
    start = await client.authStart({ provider: "siwe", turnstileToken });
  }

  if (start.type !== "nonce") {
    throw new Error(`Unexpected authStart response: ${start.type}`);
  }

  const message = client.buildSiweMessage({
    address,
    nonce: start.nonce,
    domain: window.location.host,
    uri: window.location.origin,
    statement: "Sign in to the app",
    chainId: 1,
  });
  const signature = await signMessage(message);
  const { accessToken } = await client.verify({ message, signature });
  return accessToken;
}
The same challenge-handling pattern applies to siws and email providers — only the follow-up step after receiving the nonce or magic link differs.

Response matrix — POST /auth/start

ScenarioHTTPBody
Below threshold, or bot protection disabled200{ type: "nonce" | "magic_link" | ... }
Challenge required, no turnstileToken submitted200{ type: "challenge_required", siteKey }
Challenge required, valid turnstileToken200{ type: "nonce" | "magic_link" | ... }
Challenge required, invalid or reused token403{ message: "Bot protection verification failed." }
Rate limit breached429{ message: "Too many requests. Retry after Xs.", retryAfter }

Standalone bot protection

The /bot-protection/* endpoints let you use the same Turnstile widget to gate any action in your product you want to shield a sensitive action from automated abuse. The API surface is exactly two endpoints: fetch the site key, verify the token.

GET /bot-protection/site-key

Returns the Turnstile site key registered for your app so you can render the widget in any part of your UI.
x-app-id
string
required
Your application ID.
siteKey
string | null
The Turnstile site key to pass into the widget as data-sitekey (or the equivalent prop in a React/Vue wrapper). Returns null when no widget is registered for the app.
curl https://api.agg.market/bot-protection/site-key \
  -H "x-app-id: $APP_ID"
{ "siteKey": "0x4AAAAAAA..." }

POST /bot-protection/verify

Verifies a Turnstile token against the app’s linked widget. Tokens are single-use — a second verification of the same token always fails.
x-app-id
string
required
Your AGG app ID. Must match the app the API key was issued for.
x-app-api-key
string
required
Your app-scoped API key
turnstileToken
string
required
The token returned by the Turnstile widget. Maximum 2048 characters.
curl -X POST https://api.agg.market/bot-protection/verify \
  -H "x-app-id: $APP_ID" \
  -H "x-app-api-key: $APP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "turnstileToken": "cf-token-here" }'
ResponseMeaning
200 { "success": true }Token is valid — proceed with the gated action.
400 Bot protection is not configured for this app.No widget is linked to the app.
401 Invalid API keyMissing, malformed, revoked, or expired x-app-api-key, or x-app-id does not match the key’s app.
403 Invalid Turnstile token.Empty, oversize, or malformed token.
403 Bot protection verification failed.Cloudflare rejected the token or it was already consumed.
429Per-IP or per-app rate limit exceeded.

Example — gating any action

Site key lookup happens in the browser (public, safe). Token verification happens on your server (protected by your API key). Frontend — collect the token and post it to your own backend:
async function submitGatedAction(payload: ActionPayload) {
  // 1. Fetch the site key once — safe to call from the browser with just x-app-id.
  const { siteKey } = await fetch("https://api.agg.market/bot-protection/site-key", {
    headers: { "x-app-id": APP_ID },
  }).then((r) => r.json());

  if (!siteKey) {
    // No widget registered — proceed without a challenge.
    return submit(payload);
  }

  // 2. Render the widget and collect a token.
  const turnstileToken = await renderTurnstileWidget({ siteKey });

  // 3. Post the token + payload to YOUR backend (never call /bot-protection/verify directly).
  const res = await fetch("/api/my-gated-action", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...payload, turnstileToken }),
  });
  if (!res.ok) throw new Error("Bot protection verification failed");

  return res.json();
}
Backend — verify the token with your API key before executing the action:
Need to create an API key or looking for the full reference on x-app-api-key (scopes, rotation, requireApiKey lockout, per-key rate limits)? See Server API Keys.
// Example: Node.js handler running on your server.
// process.env.AGG_APP_API_KEY is your app's secret API key — never expose it to the client.
export async function handleGatedAction(req, res) {
  const { turnstileToken, ...payload } = req.body;

  const verifyRes = await fetch("https://api.agg.market/bot-protection/verify", {
    method: "POST",
    headers: {
      "x-app-id": process.env.AGG_APP_ID,
      "x-app-api-key": process.env.AGG_APP_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ turnstileToken }),
  });

  if (!verifyRes.ok) {
    return res.status(403).json({ error: "Bot protection verification failed" });
  }

  // Verification passed — execute the gated action.
  const result = await performAction(payload);
  return res.json(result);
}
Each Turnstile token can only be verified once. If you pre-verify a token against /bot-protection/verify and then submit the same token to another endpoint, the second call will fail. For any given user action, collect a fresh token from the widget before submitting.

Rendering the Turnstile widget

Rendering the widget is a three-step process on the client:
  1. Load the Turnstile script — Cloudflare’s loader (api.js) exposes a global window.turnstile object with the widget API.
  2. Mount the widget into a DOM element — pass the siteKey fetched from GET /bot-protection/site-key (or the siteKey returned in a challenge_required response).
  3. Collect the token in a callback — the widget fires a callback with a short-lived token once the user passes the challenge. Submit that token alongside the user’s action.

Step 1 — Load the Turnstile script

Add the loader to your document <head> once, on every page that might render a widget. The async defer attributes keep it from blocking initial render:
<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
  async
  defer
></script>
In a React/Next.js app, load it once in your root layout (e.g. _app.tsx or layout.tsx) using Next’s <Script> component or a one-shot effect:
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Script
          src="https://challenges.cloudflare.com/turnstile/v0/api.js"
          strategy="afterInteractive"
        />
        {children}
      </body>
    </html>
  );
}

Step 2 — Declarative embed (simplest)

The quickest path is the declarative embed. Cloudflare’s loader scans the DOM for any element with class cf-turnstile and auto-renders a widget into it using the attributes you provide:
<div
  class="cf-turnstile"
  data-sitekey="0x4AAAAAAA..."
  data-callback="onTurnstileSolve"
  data-error-callback="onTurnstileError"
  data-expired-callback="onTurnstileExpire"
></div>

<script>
  window.onTurnstileSolve = (token) => {
    // Pass `token` into your submit handler — e.g. client.authStart({ turnstileToken: token })
    document.querySelector("#signin-form").dataset.turnstileToken = token;
  };
  window.onTurnstileError = () => console.warn("Turnstile error — widget reset");
  window.onTurnstileExpire = () => console.warn("Turnstile token expired — ask user to re-solve");
</script>
Use this when the widget lives on a static page (a sign-in form, a contact form, etc.) and you’re happy to read the token from a callback.

Step 3 — Programmatic rendering (React)

For SPAs — where the widget is mounted and unmounted as the user navigates — use the programmatic API. Call window.turnstile.render() to mount, reset() when the user retries, and remove() on unmount so the widget doesn’t leak across route changes.
import { useEffect, useRef, useState } from "react";

declare global {
  interface Window {
    turnstile?: {
      render: (
        el: HTMLElement,
        opts: {
          sitekey: string;
          callback: (token: string) => void;
          "error-callback"?: () => void;
          "expired-callback"?: () => void;
        },
      ) => string;
      reset: (widgetId: string) => void;
      remove: (widgetId: string) => void;
    };
  }
}

export function useTurnstile(siteKey: string | null) {
  const containerRef = useRef<HTMLDivElement>(null);
  const widgetIdRef = useRef<string | null>(null);
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    if (!siteKey || !containerRef.current || !window.turnstile) return;

    widgetIdRef.current = window.turnstile.render(containerRef.current, {
      sitekey: siteKey,
      callback: (t) => setToken(t),
      "expired-callback": () => setToken(null),
      "error-callback": () => setToken(null),
    });

    return () => {
      if (widgetIdRef.current) window.turnstile?.remove(widgetIdRef.current);
    };
  }, [siteKey]);

  const reset = () => {
    setToken(null);
    if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
  };

  return { containerRef, token, reset };
}
Wire it into a form component:
function SignInForm() {
  const [siteKey, setSiteKey] = useState<string | null>(null);
  const { containerRef, token, reset } = useTurnstile(siteKey);

  useEffect(() => {
    fetch("/bot-protection/site-key", { headers: { "x-app-id": APP_ID } })
      .then((r) => r.json())
      .then(({ siteKey }) => setSiteKey(siteKey));
  }, []);

  const handleSubmit = async () => {
    if (!token) return; // widget hasn't produced a token yet
    try {
      await client.authStart({ provider: "siwe", turnstileToken: token });
    } catch (err) {
      reset(); // on failure, reset so the user can solve again
      throw err;
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div ref={containerRef} />
      <button type="submit" disabled={!token}>
        Sign in
      </button>
    </form>
  );
}

Token lifetime and reset behavior

Tokens are short-lived and single-use:
  • 5-minute TTL — Cloudflare invalidates a token 5 minutes after the user solves the challenge.
  • Single-use — AGG enforces one-time verification. A token consumed by /auth/start or /bot-protection/verify cannot be reused.
  • Always collect a fresh token right before the submit action, not at page load.
  • On a failed submit, call turnstile.reset() so the user can produce a new token without a full page reload.
Cloudflare also fires expired-callback if the user leaves the widget idle too long — handle it by clearing your local token state so the submit button re-disables until the user re-solves.

Troubleshooting

SymptomLikely cause
403 Bot protection verification failed. on first submitToken expired (over 5 minutes since solve), or the widget domain is not registered for this app.
403 on a follow-up submit with the same tokenToken was already consumed — collect a fresh one from the widget.
400 Bot protection is not configured for this app.The app has no Turnstile widget linked. Re-enable via the operator endpoint, or contact AGG support.
429 on repeated /auth/start callsLayer 3 sign-in rate limit. Back off and retry after the retryAfter interval.
Widget never renders in the browserNo widget is registered for the app — GET /bot-protection/site-key returns { siteKey: null }.

Authentication

Full sign-in flow for SIWE, SIWS, email, and OAuth providers.

Token Refresh

Renew access tokens after a successful sign-in.