Destination
Sometimes the honest answer to a reporting question is a spreadsheet, and sometimes the spreadsheet is the CRM. Attribi appends a row to a Google Sheet you choose every time someone converts on your site, carrying the visit history behind that conversion rather than just the form fields. Each row also gets a Status cell. Change it to Qualified or Closed Won, add the deal value and currency, and once a day Attribi reads those cells back and treats them the way it treats a CRM stage change. Sheets connected before September 2026 get the stage columns after one more Save & Connect.
No credit card required · 5-minute setup
When a visitor converts on your site, Attribi appends one row to your sheet with the timestamp, visitor id, email, phone, how many touchpoints that person had, and how many separate sessions they took. Rows are appended, so nothing you have already written is overwritten.
Each row carries the first, second, third from last, second from last, and final touch, each with its source, medium, campaign, content, term, click ID, click ID platform and referrer. That is 40 columns of journey after the six lead fields, which is enough to rebuild most of a path in a pivot table.
Four more columns follow the journey: Attribi Lead ID, Status, Deal Value and Currency. Status is a dropdown of Lead, Qualified and Closed Won. Once a day Attribi reads those four columns back and, for any row that moved forward, records the stage and uploads it to your ad platforms the same way a CRM close would. Closed Won needs both a deal value and a three-letter currency code before it counts. If you connected before stage tracking existed, run Save & Connect once more from Connections and the columns appear.
You choose the spreadsheet and the specific tab through Google’s own file picker rather than pasting an id. Attribi requests per-file access, so the permission it holds covers the sheet you selected and nothing else in your Drive.
Authorize Google Sheets from the Connections page. Attribi asks only for permission to the files you open with it, so the consent screen does not request access to your Drive as a whole and you are not granting a blanket read of everything you own.
The file picker lists your spreadsheets; pick one, then pick the tab within it. On an empty tab Attribi writes the 50 headings for you. On a tab that already has headings, missing stage headings are added after your existing headings, no earlier than column AU, and your row-one headings are left as they are. Either way it adds the Status dropdown. Use a dedicated tab: the first 46 export fields always fill columns A to AT in Attribi’s order regardless of what your own headings say, stage values follow their corresponding headings, and rows are appended after the last row.
Rows arrive as conversions happen, each with Status set to Lead. When a lead progresses, change Status to Qualified or Closed Won; for Closed Won, also fill Deal Value and a three-letter Currency such as AED or USD. Attribi picks the change up on its next daily read. If you connected the sheet before stage tracking existed, open Connections and Save & Connect once more to add the columns. If that older sheet has no heading row and row one holds a lead, insert a blank row above it first, otherwise the stage headings land beside that lead’s data. A new row arriving with a blank Status means Attribi could not find its four headings; the Connections page shows a re-save message when that happens.
Step-by-step walkthrough: Offline Conversions from a Google Sheet: Good Enough for Longer Than You Think
When you export attribution to Google Sheets with Attribi, the sheet becomes a pipeline, not a report. Every website conversion appends one 50-column row to the tab you picked. You change the Status cell and type a deal value and currency. Once a day Attribi reads those four cells back and uploads the stage to your ad platforms.
The setup steps above say what the connection does. This is what they leave out. Google's own route, a sheet feeding Google Ads through Data Manager, is a different article: Offline Conversions from a Google Sheet.
Attribi's attribution export to Google Sheets is one appended row per website conversion, 50 columns wide: six lead cells, five touches of eight fields each, and four stage-tracker cells that Attribi reads back once a day. The row is written once, by position, and the only cells that ever change are the ones a person edits.
The row is built in lib/sheets-row.ts; the headings live in lib/sheets-columns.ts. On a tab Attribi set up from empty, row 1 reads like this.
| Columns | Heading(s) | What the cell holds |
|---|---|---|
| A | Timestamp | ISO 8601 in UTC, such as 2026-10-13T07:42:11.908Z |
| B | Visitor ID | The first-party cookie id for that browser |
| C, D | Email, Phone | As the form submitted them |
| E, F | Touchpoint Count, Unique Sessions | Touches before converting, and the visits they spanned |
| G to AT | First Touch, Second Touch, Third From Last, Second From Last, Last Touch, each with Source, Medium, Campaign, UTM Content, UTM Term, Click ID, Click ID Platform, Referrer | Forty cells; a group is blank when there were too few touches |
| AU | Attribi Lead ID | A UUID, the join key for the read-back |
| AV | Status | Dropdown of Lead, Qualified, Closed Won. Written as Lead |
| AW, AX | Deal Value, Currency | Blank. You fill them on Closed Won |
The first 46 cells are written by position, not by heading: the email lands in column C because it is the third cell in Attribi's row. Insert a column between A and AT and every later export lands one column off your headings, and nothing errors. That is why the setup step says a dedicated tab. The last four are the opposite. Before every append Attribi reads row 1 and finds the four stage headings by name, case-insensitive, at or after column AU, so a column inserted before AU moves nothing. The floor at AU exists because a customer's own column called Status inside A to AT was once adopted as the stage column and overwrote a phone number with the word Lead.
Click ID Platform uses Attribi's vocabulary (google, google_wbraid, google_gbraid, meta, tiktok, microsoft, linkedin, snapchat, dv360, twitter), so a pivot on it says which platform's click produced each lead.
The only writer of rows is /api/convert. It builds the 46-cell row, inserts the lead, and hands both to lib/sheets-append-retry.ts. Marking a lead qualified or closed won inside Attribi does not touch the sheet. A repeat submission from the same visitor within 30 minutes is folded into the existing lead and appends nothing, because the tracker fires on email blur and again on submit.
On a tab enrolled in stage tracking the append uses the Sheets API appendCells request, which takes the tab's numeric id and always writes after the last data row from column A. The older values.append locates a table by heuristic, and on a tab mixing 46-column and 50-column rows it once landed a whole row shifted right, with the lead id at AV. Tabs connected before stage tracking existed, and never re-saved, still use it.
If Google answers with a transient error, Attribi holds the export and queues a retry rather than writing a row it could not verify. The retry scans the Attribi Lead ID column first, so a lost response never produces two rows. Non-enrolled tabs have no id to check, so a transient failure drops the row, as before.
An enrolled append is three API requests: resolve the tab, read row 1, append. Google's Sheets API usage limits, verified 2026-10-12, are 300 read and 300 write requests per minute per project, 60 of each per minute per user per project, no daily cap, and a 429 when exceeded. That is roughly twenty conversions a minute through one connection before a burst is held and retried.
The pull is lib/sheets-pull.ts. It runs on the daily revenue-pull schedule, the same cadence as the CRM pulls, for every site whose Save & Connect wrote stage_tracker_ready. There is no cursor; every run is a full scan.
valueRenderOption: UNFORMATTED_VALUE. Email and phone are never requested. Unformatted matters: a currency-formatted Deal Value would otherwise arrive as AED 1,250.50 and fail to parse.lead, qualified, closed won. The dropdown is created with strict: false, so Sheets lets you type Won with a warning, but the pull treats that row as blank.id=in.(...) filter used to fail the whole 200-id chunk and hide every genuine lead in it.valueSkipped, a Currency that is not three letters as currencySkipped. The lead still gets qualified if it was not. The close waits.markLeadQualified and markLeadClosedWon functions a CRM pull calls. Skips never consume the budget, and the attempt window advances a full batch each day, so a thousand waiting candidates clear in five runs even if some fail every time.qualified_at null. Tomorrow retries both.A number and a code. The parser in lib/sheets-columns.ts is more forgiving than that sounds.
| Cell | Accepted | Rejected |
|---|---|---|
| Deal Value | 1250, 1,250, 1,250.50, 1 250, 1.250,50 (decimal comma), any numeric cell whatever its number format | blank, 0, a negative, AED 1250, a checkbox |
| Currency | any three letters in any case; aed becomes AED | blank, a symbol, AED followed by anything |
The dot-only case 1.250 reads as 1.25, because without a locale a thousands dot cannot be told from a decimal point. Type 1250 and it never comes up.
Currency is required rather than defaulted on purpose. The shared close function coerces a blank currency to USD and every platform upload sends the stored currency, so an AED deal with a blank cell would reach Google Ads and Meta as a USD deal of the same number. There is no site-level default currency yet. Editing Deal Value after the close has been read changes nothing.
One file per connection, because you chose it. The OAuth request in app/api/oauth/google-sheets/init/route.ts asks for a single scope, drive.file, which Google's Drive scopes page (verified 2026-10-12) lists as non-sensitive and describes as access to Drive files you open with the app. Nothing about the rest of your Drive is granted.
The grant for a particular file is issued by the Google Picker when you select it, and only if the Picker was built with setAppId, which Google's PickerBuilder reference describes as the id of the application needing to access the user's files. From 2026-04-16, when the Picker replaced a broader Drive scope, until 2026-08-26, our Picker was built without that call. Every pick looked successful and every tabs fetch then returned 404, because no grant existed; no customer completed a connection through the Picker in those four months. Recorded as G60, the app id is now derived server-side from the OAuth client id and pinned by a test.
Two consequences of the per-file scope. A file picked under one Google account cannot be reached by re-consenting with another; pushes fail with the re-pick message and nothing is written anywhere. And deleting the spreadsheet, or revoking the app in your Google account, returns 403 or 404 on the next append; reconnecting OAuth does not fix it, picking the file again does.
The card shows one of three badges from lib/integration-health.ts. Only lasting failures move it; a 429 or a 5xx is logged and retried, never written to the ledger.
| Badge | When | What to do |
|---|---|---|
| Connected | No stored error. A sheet that has never received a row is Connected by default | Nothing |
| Warning | One or two failures in a row; the reason line says the last upload failed and is retrying | Wait a day. If it persists it turns red |
| Action needed | Three failures in a row with the same message, or an expired token with no refresh token | Depends on the stored message, below |
The stored message takes one of three shapes:
What the badge never shows: skipped Closed Won rows, non-UUID ids, ids from another workspace, and transitions deferred past the 200 cap. Those are counted in the job result and written to the server log as one warning line per run. There is no card signal for any of them today.
The numbers are illustrative. Picture a three-person studio on Google Ads and Meta, no CRM, a tab called Pipeline.
Week one, 11 rows arrive at Status Lead. The office manager adds a Notes column at AY, which is fine because it sits after AX.
Week two, she changes four rows to Qualified. The next morning's pull fires four qualified events to both ad accounts. A fifth row she set to Qualified was pasted in from a form email; it has no Attribi Lead ID and is ignored.
Week three, the first deal closes: Status Closed Won, Deal Value 18,500, Currency aed. The pull reads 18500 and AED and uploads the close. A second close the same day has the currency blank. It is counted as currencySkipped, the lead is marked qualified, and the close waits. Nothing on the card says so. She notices a week later when Meta's purchase count is one short, types AED, and the next run sends it.
Week four, 38 rows: 9 Qualified, 3 Closed Won with values, 1 still blank. The tab is 38 rows into a 5,000-row scan and about 1,900 cells into Google's stated ceiling of 20 million cells per spreadsheet, verified 2026-10-12. If a page tells you that limit is 10 million, it is out of date.
Not when it fills up. At 40 leads a month the 5,000-row scan lasts ten years, and an archive tab resets it. The signals are about people and sources.
Hand over when leads arrive from somewhere other than the website. The sheet cannot create a lead from a row you type, so phone enquiries never reach the ad platforms through it. A CRM pull can bring those in, and since 2026-08-10 it fires the lead stage for them as well as qualified and closed won. Hand over when two people need to own one lead, or when you need a stage between Qualified and Closed Won. Do not hand over for latency alone: the pull is daily, and so is every CRM pull.
The Sheets connection itself is ungated on every plan. Uploading the stage to one ad platform starts on Starter at 49 dollars; a CRM connection starts on Growth at 99 dollars. The sheet keeps working beside a CRM.
Can I sort, filter or add my own columns to the connected tab? Sort and filter freely; the read-back matches rows by Attribi Lead ID, not position. Add your own columns after AX. Never insert a column inside A to AT, because the 46 export cells are written by position.
What happens if I type a Status that is not in the dropdown? Sheets accepts it with a warning because the rule is not strict. Attribi does not: the pull recognises exactly Lead, Qualified and Closed Won, case-insensitive, and treats anything else as blank.
Why did my Closed Won row upload as a qualified lead and not a close? Deal Value or Currency was blank or unreadable. The pull still fires the qualified stage, skips the close, and counts the skip in the job result. Fill both cells and the close goes on the next run.
What if I connected before September 2026 and the tab has 46 columns? It keeps exporting 46-cell rows until you open Connections and Save & Connect again, which adds the four headings and the dropdown. If row 1 of that older tab holds a lead rather than headings, insert a blank row above it first.
A sheet works as a pipeline when the software is strict about what it reads and honest about what it ignores. Attribi reads four cells per row, acts only on forward moves with a value and a currency, and tells you in the logs rather than the interface when it skipped one. Every new workspace starts with 30 days on the Pro plan, no card required, which is enough to run a month of leads through a sheet and watch the stages reach your ad accounts.
Stages Attribi supports for Google Sheets.
Destination
An attribution webhook integration that POSTs signed lead, qualified and closed-won events to your endpoint, so you can send attribution anywhere.
CRM
HubSpot revenue attribution for LinkedIn, Google and Meta ads: campaign data is written onto the contact, and closed deal revenue is read back out for upload.
Ad platform
Google Ads offline conversion tracking: send qualified leads and closed-won revenue from your CRM, so your account sees CRM-verified outcomes, not just form fills.
It appears because you put it there. Attribi writes the row when the lead is captured, with Status set to Lead and Deal Value and Currency blank. Marking a lead closed won inside Attribi does not write back to the sheet. The direction for stages is the other way: you change Status to Closed Won and type the value and currency, and on its next daily read Attribi records the close and uploads it to your ad platforms. Both the value and a three-letter currency code are required for the close. With either blank, the lead is still recorded as qualified and that stage is uploaded, but the close itself is skipped, and nothing in the Attribi interface flags that yet, so if a close does not show up, check those two cells first. The sheet never receives closes Attribi recorded elsewhere; if you want those pushed out to another system, the outbound webhook fires on all three stages.
Within a day. The read runs once daily on the same schedule as the CRM revenue pulls, so a change you make just after a run waits for the next one. Only forward moves count: Lead to Qualified, Qualified to Closed Won, or Lead straight to Closed Won. Moving a row backwards changes nothing, and a lead that has already reached Closed Won in Attribi is never re-fired even if you set it back to Lead and forward again. Editing Deal Value after the close has been read is also ignored. Each run handles up to 200 stage changes; anything beyond that is picked up on the following days. The read covers the first 5,000 rows of the tab, so once a tab grows past that, move older rows to an archive tab.
Neither. It holds leads captured on your website, not everything Attribi knows: a lead that exists only because Attribi found it in your CRM never passes through the conversion path that writes rows, and anonymous visitors who never converted are not there either. In the other direction, Attribi matches rows by the Attribi Lead ID it wrote, so a row you type in by hand has no id and is ignored, as is any row from before the stage columns existed. The sheet is a place to progress leads Attribi already captured, not a way to create them.
Fifty columns. First the timestamp, the visitor id, the email, the phone, the number of touchpoints and the number of sessions. Then five blocks of eight columns, one block each for the first touch, the second touch, the third from last, the second from last, and the final touch. Every block holds source, medium, campaign, UTM content, UTM term, click ID, the platform that click ID belongs to, and the referrer. Then the four stage columns, Attribi Lead ID, Status, Deal Value and Currency, at AU to AX on a tab Attribi set up from empty, or wherever their headings sit on a tab that already had some. Attribi reads back only the heading row and those four columns; it never reads the email or phone cells of your leads. A visitor with more than five touches has the middle of their journey left out; only the touchpoint count reflects it.
Only the file you picked. The connection requests per-file access rather than full Drive access, which means Google grants Attribi permission to the specific spreadsheet you selected through the picker and nothing else. Other files, other spreadsheets and anything shared with you stay invisible to it. Disconnecting from the Connections page removes the stored token.
No. If the same visitor submits again and Attribi recognises it as the same conversion, the second submission is folded into the existing lead and no new row is appended. That keeps a sheet usable for counting without a deduplication step of your own. Genuinely separate conversions from the same person do get their own rows.
Updated
Sign up and connect it from the Connections page.
Start FreeNo credit card · Free up to 500 visitors a month