DEV Community

Gorilla Vibe
Gorilla Vibe

Posted on

Never lose a website lead again: a tiny webhook that logs every form submission before anything else runs

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);
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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)