Lead Capture Architecture for Agencies: One Form, Many Destinations
How agencies should build lead capture: one payload contract, one endpoint, and a billing proxy that fans out to email, CRM, Slack and your archive safely.
An agency we know audited fourteen client sites last spring and found eleven different ways of handling a contact form. Gravity Forms on four, Contact Form 7 on three, a Netlify Forms endpoint on two, a hand-rolled PHP mailer on one, and two sites where the form POSTed to a page that had been deleted in a redesign eight months earlier. Nobody noticed. The client with the dead endpoint had been complaining that “SEO isn’t working” the whole time.
That’s the real cost of ad-hoc lead capture. Not the hour it takes to wire up a form, but the year of silent failure afterwards, multiplied across every site you maintain. The fix isn’t a better plugin. It’s treating form submissions as an event with a defined shape, a single ingestion point, and explicit rules about where that event goes next.
Here’s how we build it now, what broke when we got it wrong, and where the whole approach is overkill.
- Define a single payload contract (canonical field names plus source metadata) before you build anything. Every downstream integration then maps from one known shape instead of from twelve form-specific shapes.
- Fan-out belongs in a billing proxy, not in the form. The browser makes one POST; the router delivers to email, CRM, Slack and your warehouse independently so one failing destination can’t block the others.
- Generate a submission ID client-side and treat it as an idempotency key. Without it, retries create duplicate CRM records, and duplicate records are how sales teams lose trust in your pipeline.
- Instrument the router, not the form. A weekly “zero submissions in 7 days” alert per site catches dead endpoints months before the client does.
- For a one-page brochure site with a single inbox destination, this architecture is wasted effort. Use a hosted endpoint and move on.
The failure mode: every site invents its own pipeline
Consider what a typical agency lead form actually does by the time a client has had it for two years. It emails the sales inbox. It also emails a second address because someone left. It pushes to HubSpot via a plugin configured by a contractor. It fires a GA4 event, sometimes. It posts to Slack, but only for the enquiry form and not the quote form, because those were built six months apart by different people.
None of that is written down. The mapping from form field to CRM property lives inside a plugin’s admin UI, which means it isn’t in version control, isn’t in your staging environment, and disappears when the site is migrated. We’ve restored sites from backups and had every form deliver to the wrong place because the integration config was stored in an options table row that the migration tool skipped.
The second failure is coupling. When the form itself is responsible for calling the CRM API, a slow or down CRM becomes a slow or failed form submission. We watched a Pipedrive timeout turn into a 30-second spinner on a client’s contact page during a campaign. Visitors hit submit twice, then left. The lead existed nowhere.

Design the payload contract before you touch a form
Start with the shape of the event. Not the form markup, not the CRM fields: the event. We use a flat object with a small set of canonical keys and everything else nested under custom:
{
"submission_id": "01JGQ7X3M4K9V2NZ8P5T6RBWQE", // ULID, generated client-side
"form_id": "acme-enquiry-v2",
"received_at": "2026-02-11T09:14:22.118Z",
"source": {
"site": "acme.com",
"page_url": "https://acme.com/services/logistics",
"referrer": "https://www.google.com/",
"utm": { "source": "google", "medium": "cpc", "campaign": "q1-logistics" },
"gclid": "Cj0KCQiA..."
},
"person": {
"email": "[email protected]",
"full_name": "Dana Pryce",
"phone": "+44 7700 900123",
"company": "Northwind Ltd"
},
"message": "Need 3PL quote for 400 pallets/month",
"consent": { "marketing": false, "terms": true, "text_version": "privacy-2026-01" },
"custom": { "budget_band": "25k-50k", "timeline": "Q2" }
}
Two decisions in there matter more than the rest. First, person.email is always person.email, on every site, forever. The moment one client’s form sends email_address you have a special case in the router, and special cases breed. Second, source is captured automatically, not typed by the user. Attribution data that depends on a human filling in “How did you hear about us?” is fiction, and clients will make budget decisions on it.
Write the contract as a JSON Schema file in a shared repo. Validate against it in the router and reject non-conforming payloads with a 422 and a useful error body. It takes an afternoon and it turns “the form is broken” into a log line that names the offending field.
Lead capture routing: one endpoint, many destinations
The browser should know about exactly one URL. Everything else is the router’s problem. The form POSTs JSON to your ingestion endpoint, the endpoint validates, persists, responds 202 in under 200ms, and then fans out asynchronously.
The front end is boring on purpose, which is the point:
const form = document.querySelector('#enquiry');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('[type=submit]');
btn.disabled = true;
const data = Object.fromEntries(new FormData(form));
const payload = {
// ULID via crypto.randomUUID() is fine too; the point is client-generated
submission_id: crypto.randomUUID(),
form_id: form.dataset.formId,
source: {
site: location.hostname,
page_url: location.href,
referrer: document.referrer || null,
utm: JSON.parse(sessionStorage.getItem('utm') || '{}')
},
person: {
email: data.email,
full_name: data.name,
phone: data.phone || null,
company: data.company || null
},
message: data.message || null,
consent: { marketing: !!data.marketing, terms: true, text_version: 'privacy-2026-01' },
custom: { budget_band: data.budget || null }
};
try {
const res = await fetch('https://intake.youragency.dev/v1/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true // survives the page unload if the user navigates fast
});
if (!res.ok) throw new Error(await res.text());
form.replaceWith(successNode());
} catch (err) {
btn.disabled = false;
showError('We could not send that. Email us at [email protected].');
// Never leave the user with no path forward when the network fails
}
});
The router itself can live anywhere that gives you a queue. We mostly use a Cloudflare Worker with Queues because the cold path is genuinely fast and the free tier covers a surprising number of client sites, but a small Node service on Fly.io or a Lambda with SQS behind it is the same architecture.
export default { async fetch(req, env) { if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 }); const body = await req.json(); const errors = validate(body); // JSON Schema check if (errors.length) { return Response.json({ errors }, { status: 422 }); } // Idempotency: same submission_id within 24h is a no-op, not a duplicate lead const seen = await env.LEADSKV.get(); if (seen) return Response.json({ status: 'duplicate' }, { status: 202 }); await env.LEADSKV.put(seen:${body.submissionid}seen:${body.submissionid}, '1', { expirationTtl: 86400 }); body.received_at = new Date().toISOString(); body.source.ip_country = req.headers.get('cf-ipcountry'); // Durable write first, delivery second. If the queue send fails we still have the lead. await env.LEADS_DB.prepare( 'INSERT INTO leads (id, formid, payload, receivedat) VALUES (?, ?, ?, ?)' ).bind(body.submissionid, body.formid, JSON.stringify(body), body.received_at).run(); await env.LEAD_QUEUE.send(body); return Response.json({ status: 'accepted', id: body.submission_id }, { status: 202 }); }, async queue(batch, env) { for (const msg of batch.messages) { const lead = msg.body; const routes = ROUTING[lead.form_id] ?? ROUTING.default; const results = await Promise.allSettled(routes.map((r) => deliver(r, lead, env))); const failed = results .map((r, i) => (r.status === 'rejected' ? routes[i].name : null)) .filter(Boolean); if (failed.length) { // Retry only the destinations that failed, not the whole fan-out msg.retry({ delaySeconds: 60 }); } else { msg.ack(); } } } };
Routing config is a plain object in the repo, reviewed in a pull request like anything else:
const ROUTING = {
'acme-enquiry-v2': [
{ name: 'email', type: 'email', to: ['[email protected]'], template: 'enquiry' },
{ name: 'crm', type: 'hubspot', pipeline: 'inbound', owner: 'round-robin' },
{ name: 'slack', type: 'slack', channel: '#leads-acme' },
{ name: 'archive', type: 'r2', bucket: 'lead-archive' }
],
default: [{ name: 'email', type: 'email', to: ['[email protected]'] }]
};
That single file answers “where do leads from this site go?”, a question that previously required logging into three admin panels. It’s also the thing you diff when a client says leads stopped arriving after Tuesday.
Idempotency, retries and the delivery guarantees nobody sets up
Most form stacks offer at-most-once delivery and pretend it’s exactly-once. The API call fails, the error is swallowed, the visitor sees a success message. You want at-least-once delivery plus idempotency, which in practice means three things.
Generate the ID in the browser so a double-click or a retried fetch carries the same key. Persist before you deliver, so a crash in the fan-out never loses the record. Make every destination handler idempotent where the API allows it: HubSpot, Pipedrive and Salesforce all support upsert by a unique property, so store submission_id as a custom property and upsert against it rather than blindly creating a contact.
Cap your retries and build a dead letter path. Ours is dull and effective: after five attempts the message goes to a DLQ, and a daily cron posts the count to our internal Slack with a link to replay. Over three years the two most common DLQ causes have been an expired CRM OAuth token and a client changing their Slack workspace plan. Both are invisible without a DLQ and both take under ten minutes to fix once you can see them.
Spam, consent and what you’re actually allowed to store
Honeypots still catch a large share of unsophisticated bots and cost you nothing. Add a hidden field with a plausible name, plus a timestamp check rejecting submissions completed in under about 2.5 seconds. Anything that gets through goes to Turnstile or hCaptcha, invisible mode, challenge only on suspicion. Do not put a visible reCAPTCHA checkbox on a B2B enquiry form in 2026. We’ve measured completion drops on high-intent forms every time a client insisted, and the spam was always solvable another way.
On consent, two rules we don’t bend. Marketing opt-in is a separate unticked checkbox from the submit action, and the consent record stores which version of the privacy text was shown. When a DPO asks how you obtained a contact eighteen months ago, “we have a boolean” is not an answer. Store text_version and keep the versioned copy.
Be deliberate about retention too. Raw payloads in your archive bucket should expire: 24 months is a defensible default for B2B enquiries, and the lifecycle rule takes one line of bucket config. IP addresses are personal data in the EU and UK, so we store country only unless there’s a documented fraud reason. That single decision removes a whole category of awkward conversation.
Observability: catch the dead endpoint before the client does
Instrument three things and you’ll be ahead of almost every agency.
- Submission counts per form per day, with an alert when a form that averaged more than one per day goes silent for seven. This alone would have caught the dead endpoint in the opening story.
- Destination success rate per route. A CRM that’s succeeding 94 percent of the time is broken and looks fine from the inbox, because email is still delivering.
- Time from POST to CRM record, p95. If it creeps past a few seconds, something is retrying quietly.
Add a synthetic canary: a scheduled job that POSTs a test lead with form_id: "canary" to each site’s endpoint every six hours, routed only to a monitoring destination, alerting if the 202 doesn’t come back. It’s twenty lines and it turns endpoint monitoring into something you can put on a status page for retainer clients.
The same thinking applies to anything else on the site that quietly stops working. Our diagnostic method for slow WordPress sites is the same discipline pointed at performance: measure the pipeline stage by stage rather than guessing at plugins.
When this is the wrong call
If you’re building a five-page brochure site for a plumber whose only requirement is “email me”, this architecture is malpractice. You’ll spend a day on infrastructure to save nobody any time, and in eighteen months when the site changes hands there’ll be a Worker nobody can log into. Use a hosted endpoint, point the form at it, done. That’s exactly the gap WebForms fills for static sites: a POST target that delivers to email, Slack, Telegram, Zapier or a webhook without a backend. The webhook option also gives you an upgrade path into a router later without touching the markup.
The threshold we use is roughly: more than about eight maintained sites, or any single client where a lost lead costs real money, or any requirement to route the same form to more than two destinations. Below that, hosted endpoints plus a monthly manual check are genuinely fine.
Multi-step and scheduling forms are also a different problem. The moment you introduce calendar slots and timezones, the state lives server-side before the user finishes, and the contract above doesn’t cover it. We wrote separately about booking and appointment forms for that case. Similarly, if the form’s job is list growth rather than sales enquiry, the design constraints change: see newsletter signups that convert without annoying people.
Frequently Asked Questions
Do I still need a form plugin if I have a router?
Usually not, and dropping it is often the point. The plugin’s value was its delivery integrations, which the router now owns, so what’s left is markup and validation you can write yourself. On WordPress specifically we keep a lightweight plugin for conditional logic and file uploads, but we disable its email and CRM integrations so there’s exactly one delivery path.
How do I stop duplicate CRM records when the router retries?
Generate the submission ID in the browser, store it as a custom property on the CRM object, and upsert against that property instead of creating. Every major CRM supports upsert by unique property. Keep a short-lived key in KV or Redis as a second line of defence so a same-ID POST within 24 hours returns 202 without doing any work.
Where should the router live if I don’t want to run servers?
Cloudflare Workers plus Queues and D1 covers this well, and a Vercel or Netlify function with Upstash for the queue is equivalent. The only hard requirement is durable storage before fan-out and a retry mechanism. Avoid anything that forces the HTTP response to wait on downstream API calls.
Is form routing through a third party a GDPR problem?
It’s a processor relationship, not a blocker. You need a data processing agreement with whoever handles the payload, a record of the transfer in your ROPA, and a retention policy that actually deletes. Choosing a provider with EU data residency removes the transfer question entirely, which is worth it for clients with public-sector customers.
How do I migrate existing client sites without breaking their leads?
Run both paths in parallel for two weeks. Add the router POST alongside the existing plugin delivery, compare counts daily, and only disable the old integration once the numbers match exactly. We’ve found mismatches roughly one time in five, almost always a second notification address nobody documented.
Where to start
Pick your noisiest client site: the one where a missed enquiry gets a phone call. Write the payload contract for its main form this week, stand up a single endpoint that validates, stores and emails, and add the seven-day silence alert. That’s a day of work and it already beats what most agencies have.
Add destinations after that, one per week, each behind its own route config entry. The architecture earns its keep on site number nine, not site number one, but the contract you write on day one is what makes site nine take twenty minutes instead of a day.


