Guide · SvelteKit 2

The action says fail(403).
The page hears about it in form.

SvelteKit's form actions are the natural home for this: the POST arrives in +page.server.ts where the key lives, fail() rejects with data the page can render, and use:enhance stamps the signals into the form at the last moment before submission. One trap in SvelteKit 2 deserves the headline treatment below.

01The action

// src/routes/signup/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { HUMA_API_KEY } from '$env/static/private';
import type { Actions } from './$types';

export const actions = {
  // Use the fetch from the RequestEvent, not the global one: same API,
  // but it inherits SvelteKit's instrumentation.
  default: async ({ request, fetch }) => {
    const data = await request.formData();
    const email = data.get('email');
    const rawSignals = data.get('huma_signals');

    if (typeof email !== 'string' || !email) {
      return fail(400, { email, missing: true });
    }

    try {
      const res = await fetch('https://humaverify.com/api/v1/verify', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${HUMA_API_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          userId: email,
          sessionData: rawSignals ? JSON.parse(rawSignals as string) : {}
        }),
        signal: AbortSignal.timeout(3000)
      });

      if (res.ok) {
        const verdict = await res.json();
        if (!verdict.human || verdict.agent?.is_agent) {
          // return, not throw: fail() produces an ActionFailure whose
          // data lands in the page's form prop.
          return fail(403, { email, blocked: true });
        }
      }
      // non-2xx from useHUMA: fall through, fail open
    } catch {
      // timeout or network error: our outage is not your outage
    }

    // ... create the user ...

    // OUTSIDE the try/catch, on purpose. SvelteKit 2: call redirect()
    // directly (it throws internally); a catch-all around it would
    // swallow the redirect and strand the user on the form.
    redirect(303, '/welcome');
  }
} satisfies Actions;

02The page

<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  let { form } = $props();
</script>

<svelte:head>
  <script src="https://humaverify.com/huma.js"></script>
</svelte:head>

<form method="POST" use:enhance={({ formData }) => {
  // Runs just before submission: the collector has had the whole
  // visit to observe, and the field is stamped at the last moment.
  formData.set('huma_signals', JSON.stringify(window.Huma.debug().features));
}}>
  <input name="email" type="email" value={form?.email ?? ''} />
  <input type="hidden" name="huma_signals" />
  {#if form?.blocked}<p>We could not verify this session.</p>{/if}
  <button>Sign up</button>
</form>

The one that strands your users

SvelteKit redirects work by throwing. In SvelteKit 2 you call redirect(303, ...) directly and it throws internally, which means a catch-all around it swallows the redirect: the signup succeeds, the user stays on the form, and nothing looks wrong in the logs. The verification fetch needs a try/catch for the fail-open path, so the redirect must live outside it. That is why the action above is shaped the way it is.

What bites

Why this instead of a CAPTCHA

A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.

Next.jsWordPressClerkAuth0Supabase AuthAuth.js / NextAuthFirebase Auth

SvelteKit for the app, useHUMA for humanity.

Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.

Get your API key →

The actions API, fail() semantics and the SvelteKit 2 redirect behaviour on this page were checked against the live documentation on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.

Help