If you sell through conversations, the sale never happens on your website. A person fills in a form, you talk to them, and weeks later they buy. Your ad platforms only saw the form. They never learn which of those people became customers, so they keep optimizing for more form fills, not for more sales.
This article shows how we closed that gap for a US premium breeder who sells through inquiries, using three tools that cost nothing to start: Cloudflare Workers, Google Sheets with Apps Script, and Klaviyo on its free plan. It also answers the question we get most often: will the free tiers hold up? The short version of the project is in the case study. This is the technical walk-through.
The problem in one picture
Before the project, tracking ran in the visitor's browser. That has two weaknesses. Ad blockers cut it off: in one week, 3 of 17 inquiries produced no event at all, and the confirmation email that depended on the event never went out. And the browser only knows about the form submission. It cannot know that the owner qualified the lead on Tuesday or closed the sale on Friday.
The fix is to move the important work to a server and to give the owner one place to record outcomes.
The stack
- Cloudflare Workers (free plan): a small script that receives every inquiry and every status change.
- Google Sheets and Google Apps Script: the table the client owns, plus a script that reacts when the owner edits a status.
- Meta Conversions API: the server-to-server channel for sending events to Meta.
- Klaviyo (free plan for now): receives a server event for each inquiry and sends the confirmation email.
How it flows
Site form | v Cloudflare Worker ---> Google Sheet (one row per inquiry) | \ | ---> Meta Conversions API (Lead) | \ | ---> Klaviyo event (confirmation email) | Owner sets Status in the Sheet (Qualified / Won / Lost + amount) | v Apps Script trigger ---> Worker ---> Meta (QualifiedLead / Purchase)
Step by step
1. The form sends the inquiry, plus where the person came from
A small script on the site saves the traffic source the moment a visitor lands: UTM tags, Google and Meta click IDs, and the Meta browser cookies. It keeps them for 90 days, because people often inquire days after the first click. When the form is submitted, that data travels with the inquiry.
One detail matters here. The script sends the inquiry only after the site confirms the submission succeeded. If you send on click, a failed captcha still counts as a lead.
2. The Worker writes the record
The Worker checks that the request comes from the client's own site, then passes the row to a Google Apps Script web app, which appends it to the Sheet. Both calls are protected by a shared secret. Every inquiry gets a unique lead ID, and a repeated ID is ignored, so a double click never creates a duplicate row.
3. The Worker sends events out
From the same request, the Worker sends the Lead event to Meta's Conversions API. It uses the same event ID as the browser pixel, so Meta counts the pair once instead of twice. It also sends a server event to Klaviyo with the lead ID as a unique key, and a Klaviyo flow turns that event into the confirmation email. If Klaviyo or Meta is down, the inquiry is still saved, and the failure is written to a log instead of breaking the form.
4. The owner decides in the Sheet
The Status column is a dropdown: New, Qualified, Won, Lost. When a sale closes, the owner enters the amount first and then sets Won. That order matters, because the amount is read at the moment the status changes.
5. A status change becomes an ad event
An Apps Script trigger fires on each status edit and calls the Worker. For Qualified, the Worker sends a QualifiedLead event. For Won, it sends a Purchase with the amount and currency. Each event carries an ID built from the lead ID and the status, so a status that is edited twice is still counted once. The event time is the time of the status change, not the time of the upload.
6. Matching, so Meta can connect the event to a person
An outcome is only useful if Meta can tie it to the right person. We send hashed email and phone, the browser cookies when we have them, the click ID, and a stable random visitor ID created on the first visit. After adding these, Meta's match quality score for the Lead event went from 7.8 to 9.3 out of 10. QualifiedLead and Purchase score lower (8.0 and 6.6 at the last check), probably because some sales arrive without paid-source data. That is a hypothesis we will test as more sales come in.
Four traps we hit
- Apps Script triggers need explicit permissions. A trigger can be installed successfully and still fail with "no permission to call UrlFetchApp". Declaring the OAuth scopes in the manifest and re-approving the script fixed it.
- Test events live on separate channels. In Meta's Events Manager, website events and offline (CRM) events have different test codes, and the list of offline test events is not shown in the interface. We verified the CRM events from Meta's API response instead.
- A theme can send its own Lead. The site already had a browser Lead event on the thank-you page. Left in place next to the server event, it would have counted every lead twice.
- Pasting code into web editors adds characters. Cloudflare and Apps Script editors auto-close brackets and comment blocks. Always check the end of the file after pasting.
Will the free tiers be enough?
Yes, by a wide margin. The limits below are the published ones at the time of writing.
- Cloudflare Workers Free: 100,000 requests per day. One inquiry costs one request, and each status change costs one more, so about three in total. At roughly 7 inquiries a day the Worker handles around 20 to 25 requests a day, which is 0.03% of the limit. That figure is an estimate from inquiry volume, not a dashboard reading. The limit would be reached at roughly 30,000 inquiries a day.
- Google Apps Script: on a regular Google account, 20,000 outgoing calls and 90 minutes of trigger runtime per day. On Google Workspace the limits are 100,000 calls and 6 hours. A status change uses one call and a couple of seconds, so the practical ceiling is in the thousands of status changes a day, far more than one owner can enter by hand.
- One Cloudflare account for several clients: the 100,000 requests are shared by all Workers on the account. Ten clients with 100 inquiries a day each would use about 3,000 requests, around 3% of the limit.
- Bots: requests from other sites are rejected by the Worker, but they still count toward the limit. A spam burst would have to reach tens of thousands of requests in one day to matter.
If the limit is ever exceeded, Cloudflare returns an error for the rest of that day, and the counter resets at midnight UTC. A paid Workers plan exists for that case. Klaviyo is the one part that grows with the business: the free plan covers a small list, and a paid plan comes with a bigger one, which is the normal cost of email marketing and not of the automation.
What this does not do yet
- Campaigns still optimize on Lead. Meta wants roughly 50 target events per ad set per week to optimize on an event. With one or two sales a week, QualifiedLead and Purchase work as quality signals and as a base for audiences, not yet as the optimization goal.
- Google Ads does not receive the outcomes yet. Uploading offline conversions with the saved click ID is the next step.
- A person who submits the form twice gets two confirmation emails. We chose simplicity over a deduplication rule.
Who this is for
This pattern fits any business where the sale happens in a conversation and where ads bring the inquiries: service businesses, project-based B2B, breeders, clinics, local professionals. It does not need a CRM subscription to start. It needs a form, a table the owner will actually update, and careful attention to the details above.
If you want this set up for your business, send us a request and tell us how your inquiries turn into sales.