Webhook receipt is not CRM proof
The form can reach the adapter while a later CRM timeout, rate limit, or validation error still loses the sales handoff.
Fillout form CRM delivery
Authenticate the Fillout webhook, preserve its Submission ID, allowlist approved contact fields, and deliver the mapped lead through LeadProof with a verifiable receipt.
Paid plan uses secure Stripe checkout · plan assigned after verified payment · activate with the checkout emailFillout can POST form responses to a webhook and gives every response a Submission ID, but a successful webhook test or accepted callback is not proof that the final CRM stored the lead. A production adapter should authenticate the inbound request, discard unapproved fields, keep the Submission ID stable through recovery, and record the downstream LeadProof receipt.
Fillout form -> secret-checked adapter -> durable queue -> LeadProof -> CRMThe form can reach the adapter while a later CRM timeout, rate limit, or validation error still loses the sales handoff.
Fillout submissions can contain changing question names and types; the adapter should forward only the contact fields approved for the destination.
Using a new timestamp during a retry turns one Fillout submission into a second delivery event instead of recovering the original.
Implementation
Keep the tools that create and process the lead. Standardize only the fragile handoff between them.
Platform reference: Fillout webhook documentation ↗Open sandbox instructions →https://leadproof.jessesay.chatgpt.site/api/v1/leadsimport crypto from "node:crypto";
function sameSecret(supplied, expected) {
if (!supplied || !expected) return false;
const left = Buffer.from(supplied);
const right = Buffer.from(expected);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
function answerByName(questions, names) {
const allowed = new Set(names.map((name) => name.toLowerCase()));
const match = questions.find(
(question) =>
typeof question?.name === "string" &&
allowed.has(question.name.toLowerCase()) &&
typeof question.value === "string"
);
return match?.value;
}
app.post(
"/fillout/submission",
express.json({ limit: "64kb" }),
async (req, res) => {
if (
!sameSecret(
req.get("X-Fillout-Token"),
process.env.FILLOUT_WEBHOOK_TOKEN
)
) {
return res.sendStatus(401);
}
const submissionId = req.body?.submissionId;
const questions = Array.isArray(req.body?.questions)
? req.body.questions
: [];
if (typeof submissionId !== "string" || !submissionId) {
return res.sendStatus(400);
}
const lead = {
name: answerByName(questions, ["name", "full name"]),
email: answerByName(questions, ["email", "work email"]),
phone: answerByName(questions, ["phone", "phone number"])
};
if (!lead.name || !lead.email) return res.sendStatus(202);
await deliveryQueue.send({ submissionId, lead });
return res.sendStatus(202);
}
);
export async function deliverFilloutLead({ submissionId, lead }) {
const response = await fetch(
"https://leadproof.jessesay.chatgpt.site/api/v1/leads",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LEADPROOF_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": submissionId
},
body: JSON.stringify({
destination: process.env.CRM_WEBHOOK_URL,
...lead,
source: "fillout_form"
})
}
);
if (!response.ok) throw new Error("Lead delivery failed");
const delivery = await response.json();
await persistReceipt(submissionId, delivery.receipt?.id);
}Questions
Fillout documents a unique Submission ID for each response and includes submissionId in the webhook-compatible response shape, making it the durable identity for retries and replay.
Fillout supports custom webhook bodies and headers, but a small adapter is safer when question names vary because it can authenticate the callback, allowlist fields, queue work, and retain the receipt.
No. Send only the approved mapped lead fields. LeadProof production delivery stores operational metadata and a payload fingerprint rather than intentionally retaining the complete payload.
Get an explainable risk score, prioritized fixes, and the right LeadProof plan.