The main signup guard call. Call it once, when you create the account. It runs the email checks, compares the signup with the app's recent signups, stores this signup as keyed hashes, and returns a recommendation. Requires a secret key (sk_*) and SDK 1.15.0 or later. Never call it on login.
Signature
vevee.guard.signup(p: {
userId: string; // your id for the new account, 1 to 200 chars
email: string; // max 320 chars
device?: string; // the blob from collectDevice() in the browser
context?: {
ip?: string; // max 64 chars; used in memory only, never stored
ja4?: string; // TLS fingerprint, max 128 chars
userAgent?: string; // max 512 chars
};
}): Promise<GuardSignupResponse>Build context with guardContextFromHeaders(req.headers) from @vevee/sdk. It reads the IP from x-vercel-forwarded-for, cf-connecting-ip, x-real-ip, and only then the left-most x-forwarded-for entry, which a client can spoof. Behind any other proxy, pass ip yourself. The device blob comes from collectDevice() in @vevee/sdk/browser; an oversized blob is ignored, not rejected.
Example
// app/api/signup/route.ts
import { guardContextFromHeaders } from '@vevee/sdk';
import { vevee } from '@/lib/vevee'; // createClient({ apiKey: 'sk_live_...' })
export async function POST(req: Request) {
const { email, device } = await req.json();
const userId = crypto.randomUUID();
const verdict = await vevee.guard.signup({
userId,
email,
device,
context: guardContextFromHeaders(req.headers),
});
switch (verdict.recommendation) {
case 'block':
return Response.json({ error: 'Use a different email.' }, { status: 400 });
case 'deny_free':
await createAccount(userId, email, { freeCredits: 0 });
break;
case 'review':
await createAccount(userId, email);
await queueReview(userId, verdict.reasons);
break;
default:
await createAccount(userId, email);
}
return Response.json({ ok: true });
}Response
{
email: {
email: string;
normalized: string; // dots and +tags removed where the provider ignores them
domain: string;
valid: boolean;
deliverable: boolean | null; // null = unknown (DNS timeout)
disposable: boolean;
relay: boolean; // Hide My Email, Firefox Relay and similar
domainAgeDays: number | null;
suggestion: string | null; // 'gmail.com' for 'gmial.com'
sources: string[]; // diagnostic only, do not branch on it
risk: 'low' | 'medium' | 'high' | 'unknown';
};
device: {
risk: 'low' | 'medium' | 'high' | 'unknown'; // unknown = no device signal or degraded
score: number; // best match score, 0 to 100+
matches: number; // earlier signups at or above the medium threshold
};
risk: 'low' | 'medium' | 'high' | 'unknown'; // worst of email and device
mode: 'off' | 'observe' | 'enforce';
recommendation: 'allow' | 'review' | 'deny_free' | 'block';
reasons: GuardReason[]; // strongest first
planId: string | null; // your deny_free plan, when set and it exists
degraded: boolean;
unavailableReason: 'guard_cap_reached' | 'guard_unavailable' | 'guard_disabled' | null;
}Branch on recommendation and show reasons to your team. In observe mode the recommendation is always allow; the dashboard shows what enforce would have done. The actions behind each reason are set per app on the Signup guard page.
Reasons
| Reason | Meaning | Default action |
|---|---|---|
invalid_email | Not an email address (no @, or a malformed domain). Always block. | block |
blocked_domain | The domain is on your app's block list. | block |
disposable_email | A throwaway email service (list or approved burner mail server). | block |
undeliverable_domain | A valid address whose domain cannot receive mail. | block |
email_seen | The same normalized email signed up before on this app. | deny_free |
device_high | Strong match with an earlier signup (score at or above the high threshold, default 70). | deny_free |
device_medium | Partial match (score at or above the medium threshold, default 40). Identical phones on one Wi-Fi land here, so never set it to deny_free. | review |
new_domain | The domain is younger than your new-domain window (default 30 days). | review |
suspect_mail_server | The domain uses a mail server seen with burner domains, waiting for review by the Vevee team. Once approved it counts as disposable_email. | review |
relay_email | A privacy relay address. | allow |
checks_unavailable | Checks could not run and your app is set to review in that case. | review |
Never throws
Every failure (network, 3 second timeout, 4xx, 5xx, a pk_* key) resolves to an unavailable verdict: risk: 'unknown', unavailableReason: 'guard_unavailable', and the recommendation from the guardUnavailable client option (default 'allow'). The error goes to onGuardError if you set it.
const vevee = createClient({
apiKey: process.env.VEVEE_SECRET_KEY!,
guardUnavailable: 'allow', // or 'review'
onGuardError: (err) => console.warn('guard unavailable', err instanceof Error ? err.message : err),
});A pk_* key is refused inside the SDK without a request, so shipping one means every signup is silently allowed. Watch onGuardError.
Allowance and degraded answers
Each call counts once against the guard allowance (20% of your plan's operations, separate from metering; guard calls never cause workspace_limit_reached). Past the allowance the check runs degraded: disposable lists, burner mail servers, your block list and repeated emails still run, DNS, domain age and device matching are skipped, and the answer carries degraded: true and unavailableReason: 'guard_cap_reached'. Degraded answers do not count. See the guide for the details.
Errors (raw HTTP only)
The SDK never surfaces these; it returns the unavailable verdict instead.
| Code | Status | When |
|---|---|---|
invalid_key | 401 | Missing or revoked key. |
requires_secret_key | 403 | Called with a pk_* key. |
invalid_request | 400 | Missing userId, or a field over its length limit. |