One file in server/api.
The suffix does the routing.
signup.post.ts only answers POST, readValidatedBody rejects malformed input before your code runs, and createError(403) is the whole rejection. The details worth a page are where server/ lives in Nuxt 4 and what $fetch throwing on non-2xx means for the fail-open path.
01The server route
// server/api/signup.post.ts
// server/ stays at the PROJECT ROOT in Nuxt 4. Only client code moved
// into app/. The .post suffix routes the method: a GET gets 405 for free.
// defineEventHandler, readValidatedBody, createError, useRuntimeConfig and
// $fetch are all auto-imported in server routes. zod is the one install.
import { z } from 'zod'
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(8),
humaSignals: z.record(z.string(), z.unknown()), // window.Huma.debug().features
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.parse)
const config = useRuntimeConfig(event) // pass event so env overrides apply
let verdict: { human: boolean; agent?: { is_agent?: boolean } } | null = null
try {
verdict = await $fetch('https://humaverify.com/api/v1/verify', {
method: 'POST',
headers: { Authorization: `Bearer ${config.humaApiKey}` },
body: { userId: body.email, sessionData: body.humaSignals },
timeout: 3000, // ofetch aborts past 3s
retry: 0, // explicit; ofetch never retries POSTs anyway
})
} catch {
// $fetch throws on non-2xx AND on timeout, so a useHUMA 5xx and an
// outage land in the same place: fail open. Our outage is not yours.
verdict = null
}
if (verdict && (!verdict.human || verdict.agent?.is_agent)) {
throw createError({
statusCode: 403,
statusMessage: 'Verification failed',
message: 'We could not verify this session.',
})
}
// ... create the user (DB insert, session) ...
return { ok: true }
})02The key stays server-side
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
humaApiKey: '', // server-only; set with the NUXT_HUMA_API_KEY env var
},
})03The page sends the signals
<!-- app/pages/signup.vue -->
<script setup lang="ts">
useHead({ script: [{ src: 'https://humaverify.com/huma.js' }] })
const email = ref('')
const password = ref('')
const blocked = ref(false)
async function onSubmit() {
try {
await $fetch('/api/signup', {
method: 'POST',
body: {
email: email.value,
password: password.value,
humaSignals: window.Huma ? window.Huma.debug().features : {},
},
})
await navigateTo('/welcome')
} catch (e: any) {
if (e?.statusCode === 403) blocked.value = true
}
}
</script>What bites
server/stays at the project root in Nuxt 4. Only client code moved intoapp/. A route placed underapp/server/never runs and never errors, it is just ignored.$fetchthrows on non-2xx. That makes the catch block the single fail-open path for outages, timeouts and misconfigured keys alike, and it means you never checkres.okin Nuxt server code.readValidatedBodyneeds a validator function:schema.parsethrows a 400 for you,safeParseif you want to shape the error yourself. PlainreadBodyskips validation.- Pass
eventtouseRuntimeConfig(event)in server routes so per-request environment overrides apply. zodis the only import you install. Everything else in the route is auto-imported by Nitro.
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.
Nuxt 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 Nuxt 4 directory facts, h3 APIs and ofetch 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.