Guide

Clerk

Verify once after Clerk sign-up, then grant credits by decision.

Keep Clerk's sign-up and bot protection. Nolgard decides how much the new account gets.

Where to verify

The token comes from the visitor's browser and expires after 5 minutes. Clerk sends its user.created webhook asynchronously from its servers, without the browser token or visitor IP (Clerk webhooks). Verify in your trial or credit-granting route right after sign-up. Pass the Clerk user ID as account.id.

  1. Clerk <SignUp /> redirects new users to /onboarding.
  2. /onboarding gets a Nolgard token and calls /api/grant-trial.
  3. The route verifies and grants credits by decision.

Environment

NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/onboarding
NEXT_PUBLIC_NOLGARD_SITE_KEY=pk_...
NOLGARD_SECRET=sk_...

Redirect variables: Clerk onboarding guide.

Onboarding page

// app/onboarding/page.tsx
'use client';
import Script from 'next/script';
import { useState } from 'react';

declare global {
  interface Window {
    Nolgard: { ready: Promise<void>; execute(o: { action: string }): Promise<string> };
  }
}

export default function Onboarding() {
  const [ready, setReady] = useState(false);
  async function start() {
    await window.Nolgard.ready;
    const token = await window.Nolgard.execute({ action: 'signup' });
    await fetch('/api/grant-trial', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    });
    window.location.href = '/dashboard';
  }
  return (
    <>
      <Script
        src="https://nolgard.com/v1/client.js"
        data-site-key={process.env.NEXT_PUBLIC_NOLGARD_SITE_KEY}
        onReady={() => setReady(true)}
      />
      <button onClick={start} disabled={!ready}>Start my trial</button>
    </>
  );
}

Route

// app/api/grant-trial/route.ts
import { NextResponse } from 'next/server';
import { auth, clerkClient, currentUser } from '@clerk/nextjs/server';

const FULL_TRIAL = 100; // fallback for transport outages or unconfigured credits

export async function POST(request: Request) {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  const user = await currentUser();
  if (user?.privateMetadata.trialGranted) return NextResponse.json({ ok: true }); // once per account

  const { token } = await request.json();
  const remoteIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
  let grant = { share: 1, credits: null as number | null, requires: null };
  try {
    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,
        action: 'signup',
        account: { id: userId, email: user?.primaryEmailAddress?.emailAddress },
      }),
      signal: AbortSignal.timeout(3000),
      cache: 'no-store',
    });
    if (response.status >= 500) throw new Error('nolgard 5xx');
    const result = await response.json();
    if (result.success) grant = result.grant;
    else if (result.error === 'invalid-secret') { // config: fail closed
      return NextResponse.json({ error: 'try-again' }, { status: 503 });
    }
    else if (result.error !== 'over-quota') grant = { share: 0, credits: 0, requires: null }; // bad token
  } catch {
    // timeout or 5xx: fail open with the normal trial
  }

  const client = await clerkClient();
  await client.users.updateUserMetadata(userId, {
    privateMetadata: { trialGranted: true, credits: grant.credits ?? Math.floor(grant.share * FULL_TRIAL), nolgardGrant: grant },
  });
  return NextResponse.json({ ok: true }); // same response for every decision
}

limit gives fewer credits. shadow and block give none. The response is the same for every decision. See the updateUserMetadata Clerk reference.