Guide · Nuxt 4 (identical in Nuxt 3)

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

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

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.

Help