TLDR: Fillout's Simple webhook mode doesn't support custom headers. Stop fighting it — make your endpoint accept the secret as ?t=<secret> instead. Constant-time compare, rotatable, done.

The setup

I was wiring my business partner's prospect intake form — a public questionnaire on Fillout (a Typeform-style form builder) — directly into the law firm's practice-management app.

The goal: a prospect fills out the form, and the app automatically creates their contact records, a Potential matter, runs a conflict check, the whole thing.

The intake webhook endpoint was already there, actually. It had been deployed since May and was sitting there doing nothing, because the parser was a placeholder and nobody had configured the webhook on the Fillout side. Classic "70% done" trap.

So this was wiring + finishing, not a new build. Fine. But then I hit the wall.

The wall

The textbook move for a webhook secret is an Authorization: Bearer <secret> header.

Fillout has two webhook modes: Simple and Advanced.

Simple mode is what you want — paste a URL, Fillout sends its standard payload (questions, urlParameters, submissionId, the works). Clean, no surprises.

Except… no header options. At all. Simple mode cannot set a custom Authorization header.

The trap

So you look at Advanced mode and think: there's my out.

Advanced gives you a custom request builder. You can set headers — except the header value is a form-field picker. It's designed for cases where the header value should come FROM the form response. Not for a static bearer token.

You also lose the standard payload. In Advanced mode you have to rebuild the entire body yourself, field by field. Every question mapped manually.

I was not going to do that. Not for a low-sensitivity intake webhook with one form.

The fix that worked

Stop fighting the tool. Change what your endpoint accepts.

I added ?t=<secret> to the webhook URL and made the route handler verify it with a constant-time compare — same logic you'd use for a bearer token, just from the query string instead of the header.

const token = req.nextUrl.searchParams.get('t') ?? ''
const expected = process.env.FILLOUT_WEBHOOK_SECRET ?? ''
if (!timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

That's it. Secret-in-URL isn't ideal for high-sensitivity endpoints, but for a prospect intake form it's absolutely acceptable — and it's rotatable (update the env var, update the URL in Fillout, done).

The other thing that bit me (almost)

Fillout's Test button sends a sample payload with ALL values null.

No name, no email, nothing. Just a ping to confirm your endpoint is alive.

If your parser throws a 422 because it can't find an email on a null payload, Fillout shows "Request failed" in the UI and may gate activation of the webhook entirely.

Your endpoint needs to return 200 on empty pings. Detect the null/empty state early, ack it, return clean.

if (!submissionId) {
  return NextResponse.json({ ok: true, skipped: 'empty-ping' })
}

Save yourself the confusion.

Why this matters to me

This whole intake pipeline existed to get rid of manual re-entry. A prospect fills out a form, and my business partner's system already knows about them — conflict-checked, typed, with all the parties recorded.

The ?t= thing is a two-line fix. But I nearly went down the Advanced-mode rabbit hole and would have spent an hour manually mapping fields just to get a static token into a header. Sometimes the right answer is: don't bend the tool, bend the interface instead.

P.S. Fillout URL params (?matterId=xxx&email=yyy) are also GREAT for pre-filling form fields AND they round-trip into the webhook payload as urlParameters — if you ever need to correlate a submission back to an existing record in your system, that's the clean way to do it.