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
fail()is returned,redirect()throws. Mixing up which is which produces either a swallowed redirect or an unhandled ActionFailure.fail()data must be a plain serializable object. It lands in the page'sformprop (andpage.form).- Use the
fetchfrom the action's RequestEvent rather than the global one. Same API, inherits SvelteKit's instrumentation. AbortSignal.timeout()is the idiomatic server-side timeout; there is no framework-level fetch timeout. SvelteKit 2 requires Node 18.17+, so it is always available.- A malformed
huma_signalsJSON parse throws inside the try block, which fails open by design. A bot posting garbage gets scored on an empty session rather than crashing the action.
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.
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.