Most lead forms do several things in a row: send an e-mail, push to a CRM, fire an analytics event, show a thank-you page. When one step fails (an expired CRM token, a mail quota, a timeout), the lead is often simply gone, and nobody notices until sales asks why enquiries dropped. The fix is boring and effective: write every submission to an append-only log first, then do everything else.
Below is a minimal pattern that works on any serverless platform with a fetch-style handler (Cloudflare Workers, Vercel/Netlify functions, Deno Deploy).
1. Validate, then log first
// lead.js - POST /api/lead
export default async function handler(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const data = Object.fromEntries(await request.formData());
const lead = {
id: crypto.randomUUID(),
receivedAt: new Date().toISOString(),
name: (data.name || "").slice(0, 120),
email: (data.email || "").slice(0, 200),
phone: (data.phone || "").slice(0, 40),
message: (data.message || "").slice(0, 2000),
page: request.headers.get("referer") || "",
utm: (data.utm || "").slice(0, 300),
};
// Honeypot: real users never fill this hidden field.
if (data.company_website) return Response.redirect(new URL("/thanks", request.url), 303);
if (!lead.email.includes("@") && !lead.phone) return new Response("Missing contact", { status: 400 });
// Step 1: durable log. If this fails, fail loudly - nothing else should run.
await env.LEADS.put(`lead:${lead.receivedAt}:${lead.id}`, JSON.stringify(lead));
// Step 2: everything else, best effort.
const results = await Promise.allSettled([
notifyEmail(lead, env),
pushToCrm(lead, env),
]);
const failed = results.filter(r => r.status === "rejected").length;
if (failed) await env.LEADS.put(`retry:${lead.id}`, JSON.stringify({ lead, failed }));
return Response.redirect(new URL("/thanks", request.url), 303);
}
env.LEADS here is a key-value store, but any append-only store works: a database table, an object bucket, even a spreadsheet API. What matters is that it is simple, independent of the CRM, and written before anything that can fail for business reasons.
2. Make the side effects retryable
async function pushToCrm(lead, env) {
const res = await fetch(env.CRM_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.CRM_TOKEN}` },
body: JSON.stringify({ email: lead.email, name: lead.name, source: lead.page, externalId: lead.id }),
});
if (!res.ok) throw new Error(`CRM ${res.status}`);
}
Sending your own externalId lets the CRM de-duplicate if a retry succeeds after a partial failure. A scheduled job (every 15 minutes is plenty) reads the retry: keys, tries again, and deletes the key on success.
3. Watch the gap
Once a day, compare two numbers: leads in the log and leads in the CRM for the same period. They should match. When they do not, you have the exact records to recover, with timestamps and source pages, and a broken integration shows up within a day instead of at the end of the month.
4. Keep the privacy basics
- Store only the fields the form asks for, and set a retention period for the raw log.
- Keep tokens in environment secrets, never in front-end code.
- Tell visitors in the privacy notice where enquiries are stored and for how long.
Why this matters for marketing
Paid campaigns are judged on cost per lead. A silent 10% loss of submissions makes every channel look 10% worse and can push budget decisions in the wrong direction. Logging first turns "we think the form works" into a number you can check.
We build this kind of plumbing as part of our web and platform development work.
Written with AI assistance for the Gorilla Vibe marketing team.
Top comments (0)