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 asurlParameters— if you ever need to correlate a submission back to an existing record in your system, that's the clean way to do it.