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.
- Clerk
<SignUp />redirects new users to/onboarding. /onboardinggets a Nolgard token and calls/api/grant-trial.- 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.