- Sep 3, 2026
- 5 min read
Server-to-Server Leads: A Key for Kinds Only the Backend May Send
All my sites send leads to one Cloudflare Worker: browser forms post
same-origin to /api/forms/submit, the Worker validates, stores in KV and
alerts my phone (the setup is in
one forms Worker for every site).
Those endpoints are public on purpose — anyone can fill in a contact form.
My website-audit tool was the odd one out. It has its own Express backend that runs the audit and stores results in PocketBase. I wanted every audit to also land in the central lead inbox, with its score, so the weekly digest and the follow-up emails see it. That means a server posting to an endpoint built for browsers — and a new problem.
Why this kind can’t be keyless
A contact form lead is just “someone typed this”. Forging one gains nothing you couldn’t get by filling the form honestly.
An audit lead is different: it says “this person ran an audit on this site
and it scored 72”. That record only means something if it came from a real
audit run. If the endpoint accepted kind: "audit" from anyone, a script
could fill my inbox with fake audits and invented scores.
So the rule became: normal form kinds stay keyless; kinds only a backend may send need a shared key.
The registry says which kinds are server-only
Each site in the Worker’s registry lists the kinds it accepts. I added one optional field:
/** Kinds only a backend may submit: they need the X-Forms-Key header (FORMS_SERVER_KEY). */
serverKinds?: readonly Kind[];
The audit site accepts exactly one kind, and it’s server-only:
{
id: "webaudit.jalcocertech.com",
profile: "growth",
kinds: ["audit"],
serverKinds: ["audit"],
hosts: ["webaudit.jalcocertech.com"],
// …
}
Every other site leaves serverKinds unset, so nothing changes for them.
The check runs before anything else
In the submit handler, the key check sits right after the body is parsed and before validation or storage:
// Kinds only a backend may submit (e.g. "audit" after a real audit run).
const requestedKind = typeof data.kind === "string" ? data.kind : "";
if (site.serverKinds?.includes(requestedKind as Lead["kind"])) {
const key = request.headers.get("X-Forms-Key") ?? "";
if (!env.FORMS_SERVER_KEY || !sameKey(key, env.FORMS_SERVER_KEY)) {
return json({ error: "forbidden" }, 403, cors);
}
}
Two details matter:
- No key configured means forbidden, not “open”. A missing secret fails closed.
sameKeyis a constant-time comparison (XOR every character, check the sum at the end), the same helper my email-provider webhook uses.
Putting the check first also gives a handy property for debugging: with the
right key and a deliberately invalid website, the Worker answers
422 invalid_website; with the wrong key it answers 403 forbidden. One
probe tells you whether the key is accepted without ever creating a lead.
What an audit lead carries
The audit kind stores two facts on top of the usual name, email and
consent: the audited website (must be http(s)://…) and the overall score,
rounded and clamped to 0–100. The phone alert shows the score and the email
domain only:
New audit - webaudit.jalcocertech.com
score 72/100 · from @shop.io
No name, no address, no audited URL in the notification. The tests assert exactly that, alongside “no key → 403”, “wrong key → 403”, “invalid website → 422” and “marketing tick → confirmation email from the audit brand”.
The backend side: best effort, never blocking
In the Express server, the forward happens after the audit has finished and PocketBase has stored its own records:
async function forwardToLeadHub({ name, email, website, score, marketing, page }) {
if (!formsServerKey) return { skipped: true };
const response = await fetch(formsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Forms-Key': formsServerKey,
'User-Agent': 'auditmagnet-server/1.0',
},
body: JSON.stringify({ kind: 'audit', name, email, website, score,
consent_marketing: marketing === true, page }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`lead hub ${response.status}: …`);
return response.json().catch(() => ({}));
}
And it’s called without await:
forwardToLeadHub({ … })
.then((r) => console.log(r?.skipped ? '📭 … skipped' : '📮 Lead forwarded to the lead hub'))
.catch((err) => console.error('❌ Lead hub forward failed:', err.message));
So:
- The visitor never waits for it. Their report is already on screen.
- A failure never breaks the audit. PocketBase and the local CSV still have the record; the log says what went wrong.
- A 10-second timeout stops a hung request from piling up.
- An explicit User-Agent, because Cloudflare had already taught me that some default client identities get blocked before they reach the Worker (error 1010).
- The marketing tick travels as
consent_marketing, which the Worker turns into double opt-in — the consent split is in the previous post.
PocketBase stays the audit tool’s own database (audit history, scores, breakdowns). The central inbox gets the lead; the app keeps its data.
Handling the key
The key never passed through a chat or a terminal scrollback:
(umask 077; openssl rand -hex 32 | tr -d '\n' > ~/.config/leads-hub/forms_server_key)
npx wrangler secret put FORMS_SERVER_KEY < ~/.config/leads-hub/forms_server_key
The same file is the source for the audit server’s .env on the homelab —
and copying it as a file rather than pasting it turned out to matter, which
is the next post.
Takeaways
- Keep public form kinds public, but give kinds that assert something (a score, a booking, a payment) an authenticated path.
- Declare it in the registry (
serverKinds), so the rule is data, reviewed in git and covered by tests. - Check the key first and fail closed when no key is configured.
- Forward best-effort: after the app has saved its own data, never awaited by the user-facing response, with a timeout and a clear log line.
- Identify your server with a User-Agent when it talks to anything behind Cloudflare.