Lead capture without handing the browser your CRM keys
How a landing-page form sends every enquiry straight into Zoho CRM, validated and tracked, without putting a single secret in the page.
The problem
My first lead form would have put the CRM keys in the page source.
The solution
A tiny server endpoint holds the keys; the browser only posts the form.
When you build a lead form for paid traffic, there are two jobs. The lead has to reach sales immediately, and nothing about how it gets there should be visible to the visitor. My first attempt at the AHA landing page did the first job and failed the second.
The tempting shortcut
Zoho CRM’s API uses OAuth. You hold a long-lived refresh token and swap it for an access token that lasts an hour. The quickest way to get a form posting into the CRM is to do that swap in the browser: call the token endpoint, then call the leads endpoint.
It works on the first try, which is exactly why it’s dangerous. To make those calls, the page needs the client ID, the client secret and the refresh token. Anyone who opens the source can copy them and do whatever that token is allowed to do in your CRM. I caught this in my own draft before the page went out, and treated it as a hard stop.
The fix: one small endpoint
The browser should only submit a form. The credentials stay on a server, so I wrote a tiny Express handler for the form to post to.
// Simplified. The real handler reads credentials from the server environment.
let cached = { token: null, expires: 0 };
async function accessToken() {
if (cached.token && Date.now() < cached.expires) return cached.token;
const res = await fetch(`${ZOHO_ACCOUNTS}/oauth/v2/token`, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: process.env.ZOHO_REFRESH_TOKEN,
client_id: process.env.ZOHO_CLIENT_ID,
client_secret: process.env.ZOHO_CLIENT_SECRET,
}),
});
const { access_token } = await res.json();
cached = { token: access_token, expires: Date.now() + 55 * 60 * 1000 };
return access_token;
}
app.post('/submit-lead', async (req, res) => {
const lead = toCrmLead(req.body); // validate + map fields
if (!lead) return res.status(422).json({ ok: false });
const r = await fetch(`${ZOHO_API}/crm/v2/Leads`, {
method: 'POST',
headers: { Authorization: `Zoho-oauthtoken ${await accessToken()}` },
body: JSON.stringify({ data: [lead] }),
});
res.status(r.ok ? 201 : 502).json({ ok: r.ok });
});
Three details mattered more than the code:
- Cache the access token for 55 minutes, a little under its one-hour life. Refreshing it on every submission is slow, and the token endpoint will throttle you.
- Validate on the server too. The browser checks fields so people get quick feedback, but the endpoint is the real gatekeeper.
- Return honest status codes. The page shows a retry message if the CRM call fails, instead of a fake “thank you”. That mattered more than I knew at the time; see A thank-you page that lied.
Making the lead useful to sales
A lead the sales team has to clean up by hand is only half delivered. The form:
- requires a 10-digit phone number and a 6-digit pincode, and opens the numeric keypad on phones;
- splits the full name into first and last name, because the CRM requires a last name;
- sets the lead source, status and city, so it lands in the right view;
- captures property stage, property type and preferred experience centre, so a salesperson can qualify the lead before the first call.
Proving it converts
Ads optimise for whatever conversion signal you give them, so the page pushes four events into the data layer: contact click, WhatsApp click, submit click and lead generated. A successful submit then redirects to a thank-you page marked noindex. That gives Google and Meta a plain URL-based conversion that keeps working even when a tag fails.
What I took away
- If the browser can see a secret, so can everyone. “Only on my test page” lasts until it doesn’t.
- A 40-line proxy is cheap insurance. It also gives you one place to add rate limits, spam checks and logging later.
- Design the lead for the person who receives it, not just for the database.