Run a small-business CRM in Google Sheets with AI-written Apps Script
For a 1–5 person services business: a structured Google Sheet plus a short Apps Script (written and debugged with Claude or ChatGPT) gives you a pipeline, follow-up reminders and a daily email digest without a paid CRM seat. Try HubSpot's free CRM first if you don't want to maintain anything.
moderateSetup 3–5 hoursPer run Daily digest arrives aut…$0 extra / monthEditorialIllustrative example
GWGoogle WorkspaceGoogle Sheets holds the data; Apps Script adds the timestamps, follow-up view and digest; MailApp sends the email. A free Gmail account also works, with lower email quotas.
CClaude (optional)Writes, adapts and debugs the Apps Script and formulas for your exact columns.
CChatGPT (optional)Alternative to Claude for writing and debugging the script.
Time
Setup: 3–5 hours (sheet design, script install, one week of parallel use) Per run: Daily digest arrives automatically; logging a deal takes under a minute
Extra cost
Google Sheets and Apps Script are included with Google Workspace and with free Google accounts. Using an AI assistant you already pay for adds nothing. The real cost is your time: about half a day to set up and ~15 minutes a month of upkeep.
Is it for you?
Today
Each person pays for a CRM seat (HubSpot Starter, Pipedrive or a light Salesforce edition). Deals live in a pipeline board; the CRM reminds you of follow-ups and logs email automatically. In practice a small team uses maybe 20% of it: a list of deals, a stage, a next step and a reminder.
With this workflow
One shared Google Sheet with Deals, Companies, Activities and Lists tabs, dropdown validation for stages and owners, and a 150-line Apps Script. Changing a deal's stage stamps the time and writes to a Stage log. Overdue rows turn red, due-today rows amber, a 'Follow-ups due' tab lists everything that needs action, and every morning each owner gets an email of their overdue follow-ups. An AI assistant writes any changes to the script or formulas when you need them.
Good fit if…
Agencies, consultants, studios, trades and other services firms with 1–5 people selling
Fewer than a few thousand open contacts and a few dozen open deals at a time
Teams already living in Google Workspace who want follow-up discipline more than automation
Founders who outgrew a to-do list but find a CRM's per-seat price hard to justify
Not for you if…
More than ~5 people selling at once — concurrent edits, ownership rules and permissions get messy in a shared sheet
Teams that rely on automatic email/calendar sync, open/click tracking or sales sequences — a sheet has none of these
Anyone who needs forecasting, weighted pipeline by rep, quotas or multi-currency reporting out of the box
Regulated businesses that need a tamper-evident audit trail, field-level permissions or data-retention controls (a sheet's version history is not a compliance audit log)
People who will not maintain a script — if nobody owns it, start with HubSpot's free CRM instead
Step by step
1
Decide whether you should build this at all
Before building, check two cheaper options:
HubSpot's free CRM is free with no expiry and covers up to 2 users and 1,000 contacts (per HubSpot's product page), including deal pipelines and email tracking. If you're two people with under 1,000 contacts, it's the no-maintenance choice.
Your current CRM's export. Export deals and contacts to CSV now (every major CRM supports it). You need this file either way.
Build the sheet if you're 3–5 people, you've hit the free tier's limits, or you want something you fully control.
2
Create the sheet structure
Create a new Google Sheet called CRM and add the tabs from the Sheet schema artifact: Deals, Companies, Activities, Lists.
Type the Deals headers exactly as listed (the script finds columns by header name, so order doesn't matter but spelling does).
Fill Lists with your stages, lead sources and team emails. Keep Won and Lost spelled exactly.
Add data validation (Data → Data validation):
Deals!F2:F → Dropdown (from a range)Lists!A2:A, Reject the input
Deals!E2:E → dropdown from Lists!C2:C
Deals!I2:I → Is valid date
Deals!D2:D → Text is valid email
View → Freeze → 1 row on every tab, and turn on a filter (Data → Create a filter) on Deals.
3
Import your existing data
Open your CRM export in a separate tab, then map it into Deals:
Paste each column under the matching header. Use =PROPER(TRIM(B2)) style helpers to clean names, then Paste special → Values only.
Make sure Stage values match the Lists tab exactly. Find mismatches with =FILTER(F2:F, ISNA(MATCH(F2:F, Lists!A2:A, 0)), F2:F<>"").
Convert follow-up dates to real dates (they should right-align). If they're text, use =DATEVALUE() or ask your AI assistant for a formula that handles your export's format.
Leave Stage changed at and Created at blank — the script fills them from now on. If you want history, paste the old CRM's stage-change dates there once.
Keep the raw export in a hidden tab called _import for 90 days in case you need to check something.
4
Install the Apps Script
Extensions → Apps Script, delete the placeholder, paste the Apps Script artifact, and Save.
Project Settings (gear icon) → set the time zone to yours. The digest runs at DIGEST_HOUR in that zone.
Reload the spreadsheet. A CRM menu appears.
Run CRM → Set up (run once) and approve the permissions Google asks for (Sheets, sending email as you, running triggers).
Test: change a deal's Stage. Within a second or two Stage changed at fills in and a row appears in Stage log.
Run CRM → Send follow-up digest now with one overdue test deal assigned to yourself.
Why two kinds of trigger: onEdit is a simple trigger, so it runs without authorisation but cannot send email, is limited to 30 seconds and does not fire for edits made by scripts or the API. The digest uses an installable time-driven trigger, which can send mail.
5
Adapt it with an AI assistant instead of hand-coding
When you want a change (an extra column, a weekly pipeline summary, a different stage list), paste the whole script plus your header row into Claude or ChatGPT with the Modify the CRM script prompt. Rules that keep this safe:
Always give the assistant your exact header row — most bugs are mismatched column names.
Ask for the complete file back, not a fragment, and keep the previous version — the simplest habit is pasting the old script into a dated Google Doc before replacing it.
Test on a copy of the spreadsheet (File → Make a copy copies the script too) before changing the live one.
If you get an error, paste the exact message from Executions (left sidebar in the Apps Script editor) using the Debug an Apps Script error prompt.
6
Run both systems in parallel for one week, then cancel
Keep the old CRM read-only for a week while the team works only in the sheet. Every day, check the digest against what the old CRM would have reminded you about. When the verification checklist passes, export a final full backup from the old CRM, store it in Drive, then downgrade or cancel. Put a monthly 10-minute calendar reminder to archive Won/Lost deals older than 12 months to an Archive tab so the sheet stays fast.
Prompts you can use now
Copy as-is, or fill in the blanks first. Highlighted parts are placeholders.
Modify the CRM script
Safely change the Apps Script for a new column, rule or report.
Fill in 4 blanks (optional)
You are an experienced Google Apps Script developer. Below is my CRM spreadsheet's current Apps Script and the exact header row of my {{sheet_name}} tab.
Header row (tab-separated, exact spelling):
{{header_row}}
Current script:
```javascript
{{current_script}}
```
Change request: {{change_request}}
Rules:
- Return the COMPLETE updated script in one code block, not a diff.
- Find columns by header name, never by hard-coded column number.
- Keep onEdit as a simple trigger: no MailApp, GmailApp or other services that need authorisation inside it.
- Stay within Apps Script quotas (6 minutes per execution; MailApp recipient limits) and read/write ranges in bulk where possible.
- After the code, list: (1) what changed, (2) any new permission Google will ask for, (3) a 3-step manual test I can run on a copy of the sheet.
Debug an Apps Script error
Turn an error from the Executions log into a fix.
Fill in 5 blanks (optional)
My Google Apps Script for a Sheets CRM fails. Explain the cause in plain English, then give the corrected function(s).
Function that failed: {{function_name}}
Trigger type: {{trigger_type}}
Exact error message and line from Executions:
{{error_message}}
Header row of the tab it reads:
{{header_row}}
Relevant code:
```javascript
{{code_snippet}}
```
If the cause is a permissions, quota or simple-trigger restriction rather than a code bug, say so and tell me what to change in the setup instead.
Weekly pipeline review from a CSV export
Get a short pipeline review without a CRM reporting module.
Fill in 4 blanks (optional)
Here is this week's export of my Deals tab as CSV (columns include Stage, Value, Owner, Next follow-up, Stage changed at, Created at):
{{deals_csv}}
Today is {{today}}. Our stages in order are: {{stage_order}}.
Produce:
1. Open pipeline value and deal count by stage (table).
2. Deals with no Next follow-up date, or a follow-up more than 7 days overdue (table: Company, Owner, days overdue).
3. Deals that have sat in the same stage for more than {{stale_days}} days, using Stage changed at.
4. Won and Lost this week, with lost reasons.
5. Three specific actions for the team, each naming a deal.
Use only the data provided. If a value is missing or ambiguous, say so instead of guessing.
Templates, scripts & configs
Sheet schema (tabs, columns, validation)
schema
SPREADSHEET: "CRM" (one Google Sheets file, shared with the team as Editors)
TAB 1 — Deals (one row per opportunity; row 1 = headers, exact names matter for the script)
| Col | Header | Type / validation |
|-----|-------------------|------------------------------------------------------------------------|
| A | Deal ID | Text. Formula in A2 (optional): =IF(B2="","","D-"&TEXT(ROW()-1,"0000")) |
| B | Company | Text. Data validation: dropdown from a range = Companies!A2:A |
| C | Contact name | Text |
| D | Contact email | Text. Data validation: "Text is valid email" |
| E | Owner | Email of the teammate who owns the deal. Dropdown from Lists!C2:C |
| F | Stage | Dropdown from Lists!A2:A (reject invalid input) |
| G | Value | Number, format Currency. Validation: number >= 0 |
| H | Next step | Text — a verb phrase: "Send revised quote", "Call re: start date" |
| I | Next follow-up | Date. Validation: "is valid date" |
| J | Stage changed at | Date-time. Filled by the script — do not type here |
| K | Created at | Date-time. Filled by the script on first stage selection |
| L | Source | Dropdown from Lists!B2:B |
| M | Lost reason | Text (fill when Stage = Lost) |
| N | Notes | Text — short; long history goes in the Activities tab |
TAB 2 — Companies
| A Company | B Website | C Industry | D City | E Size (employees) | F Notes |
TAB 3 — Activities (append-only log; use a Google Form or type directly)
| A Date | B Deal ID | C Type (Call / Email / Meeting / Note) | D Summary | E Logged by |
Tip: =FILTER(Activities!A:E, Activities!B:B = A2) in a side panel shows a deal's history.
TAB 4 — Lists (single source for dropdowns)
| A Stage | B Source | C Owner |
|------------------|-----------------|----------------------|
| Lead | Referral | you@yourfirm.com |
| Qualified | Website form | colleague@yourfirm.com |
| Proposal sent | Event | |
| Negotiation | Outbound | |
| Won | Existing client | |
| Lost | | |
Keep "Won" and "Lost" spelled exactly like this — the script treats them as closed.
TAB 5 — Follow-ups due (created by the script; formula-driven, do not edit by hand)
TAB 6 — Stage log (created by the script; one row per stage change)
PROTECTION: Data > Protect sheets and ranges → protect "Follow-ups due", "Stage log" and
Deals!J:K with "Show a warning when editing this range" so nobody overwrites them by accident.
/**
* Small-business CRM helpers for Google Sheets.
* Install: Extensions > Apps Script > paste this file > Save.
* Reload the spreadsheet, then run CRM > "Set up (run once)" and approve the permissions.
*
* Adds:
* 1. onEdit timestamp: when Stage changes, writes "Stage changed at" (and "Created at" if blank)
* and appends a row to the "Stage log" tab.
* 2. A "Follow-ups due" tab (live FILTER formula) plus red/amber highlighting on Deals.
* 3. A daily email digest of overdue / due-today follow-ups, one email per Owner, via MailApp.
*/
const CONFIG = {
DEALS_SHEET: 'Deals',
DUE_SHEET: 'Follow-ups due',
LOG_SHEET: 'Stage log',
CLOSED_STAGES: ['Won', 'Lost'],
DIGEST_HOUR: 7, // hour of day in the script time zone (Project Settings)
FALLBACK_EMAIL: '', // gets deals with an empty Owner; blank = the person who ran setup
};
const COL = {
ID: 'Deal ID', COMPANY: 'Company', CONTACT: 'Contact name', EMAIL: 'Contact email',
OWNER: 'Owner', STAGE: 'Stage', VALUE: 'Value', NEXT_STEP: 'Next step',
FOLLOW_UP: 'Next follow-up', STAGE_CHANGED: 'Stage changed at', CREATED: 'Created at',
};
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('CRM')
.addItem('Set up (run once)', 'setup')
.addItem('Rebuild follow-ups view', 'buildFollowUpsView')
.addItem('Send follow-up digest now', 'sendFollowUpDigest')
.addToUi();
}
/** Maps header text -> 1-based column number. Throws if a required header is missing. */
function headerMap_(sheet) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const map = {};
headers.forEach(function (h, i) { if (h !== '') map[String(h).trim()] = i + 1; });
Object.keys(COL).forEach(function (k) {
if (!map[COL[k]]) throw new Error('Missing column "' + COL[k] + '" on tab ' + sheet.getName());
});
return map;
}
function colLetter_(n) {
let s = '';
while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26); }
return s;
}
/* ---------- 1. Stage-change timestamps (simple trigger) ---------- */
function onEdit(e) {
if (!e || !e.range) return;
const sheet = e.range.getSheet();
if (sheet.getName() !== CONFIG.DEALS_SHEET || e.range.getLastRow() < 2) return;
let cols;
try { cols = headerMap_(sheet); } catch (err) { e.source.toast(err.message, 'CRM script'); return; }
const stageCol = cols[COL.STAGE];
if (stageCol < e.range.getColumn() || stageCol > e.range.getLastColumn()) return;
const now = new Date();
const singleCell = e.range.getNumRows() === 1 && e.range.getNumColumns() === 1;
const editor = (e.user && e.user.getEmail && e.user.getEmail()) || '';
const log = e.source.getSheetByName(CONFIG.LOG_SHEET);
const firstRow = Math.max(2, e.range.getRow());
for (let r = firstRow; r <= e.range.getLastRow(); r++) {
const stage = sheet.getRange(r, stageCol).getValue();
sheet.getRange(r, cols[COL.STAGE_CHANGED]).setValue(now);
const created = sheet.getRange(r, cols[COL.CREATED]);
if (created.getValue() === '') created.setValue(now);
if (log) {
log.appendRow([
now,
sheet.getRange(r, cols[COL.ID]).getValue(),
sheet.getRange(r, cols[COL.COMPANY]).getValue(),
singleCell ? (e.oldValue || '') : '(bulk edit)',
stage,
editor,
]);
}
}
}
/* ---------- 2. Follow-ups view + highlighting ---------- */
function buildFollowUpsView() {
const ss = SpreadsheetApp.getActive();
const deals = ss.getSheetByName(CONFIG.DEALS_SHEET);
const cols = headerMap_(deals);
const lastCol = colLetter_(deals.getLastColumn());
const f = colLetter_(cols[COL.FOLLOW_UP]);
const s = colLetter_(cols[COL.STAGE]);
const src = "'" + CONFIG.DEALS_SHEET + "'!";
const conditions = [src + f + '2:' + f + '<>""', src + f + '2:' + f + '<=TODAY()']
.concat(CONFIG.CLOSED_STAGES.map(function (st) { return src + s + '2:' + s + '<>"' + st + '"'; }));
const formula = '=IFERROR(SORT(FILTER(' + src + 'A2:' + lastCol + ', ' + conditions.join(', ') +
'), ' + cols[COL.FOLLOW_UP] + ', TRUE), "Nothing due. Nice.")';
const due = ss.getSheetByName(CONFIG.DUE_SHEET) || ss.insertSheet(CONFIG.DUE_SHEET);
due.clear();
const headers = deals.getRange(1, 1, 1, deals.getLastColumn()).getValues();
due.getRange(1, 1, 1, headers[0].length).setValues(headers).setFontWeight('bold');
due.getRange('A2').setFormula(formula);
due.setFrozenRows(1);
}
function addHighlighting_() {
const deals = SpreadsheetApp.getActive().getSheetByName(CONFIG.DEALS_SHEET);
const cols = headerMap_(deals);
const f = '$' + colLetter_(cols[COL.FOLLOW_UP]) + '2';
const s = '$' + colLetter_(cols[COL.STAGE]) + '2';
const open = CONFIG.CLOSED_STAGES.map(function (st) { return s + '<>"' + st + '"'; }).join(',');
const overdue = '=AND(' + f + '<>"",' + f + '<TODAY(),' + open + ')';
const today = '=AND(' + f + '<>"",' + f + '=TODAY(),' + open + ')';
const range = deals.getRange(2, 1, deals.getMaxRows() - 1, deals.getLastColumn());
const rules = deals.getConditionalFormatRules().filter(function (r) {
const c = r.getBooleanCondition();
const v = c && c.getCriteriaValues()[0];
return v !== overdue && v !== today; // drop our old copies so re-running setup doesn't duplicate
});
rules.push(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied(overdue)
.setBackground('#f4c7c3').setRanges([range]).build());
rules.push(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied(today)
.setBackground('#fce8b2').setRanges([range]).build());
deals.setConditionalFormatRules(rules);
}
/* ---------- 3. Daily digest (installable time-driven trigger) ---------- */
function sendFollowUpDigest() {
const ss = SpreadsheetApp.getActive();
const deals = ss.getSheetByName(CONFIG.DEALS_SHEET);
const cols = headerMap_(deals);
const values = deals.getDataRange().getValues();
const fallback = CONFIG.FALLBACK_EMAIL || Session.getEffectiveUser().getEmail();
const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
const startOfTomorrow = new Date(startOfToday.getTime() + 86400000);
const byOwner = {};
for (let i = 1; i < values.length; i++) {
const row = values[i];
const due = row[cols[COL.FOLLOW_UP] - 1];
const stage = String(row[cols[COL.STAGE] - 1]);
if (!(due instanceof Date) || CONFIG.CLOSED_STAGES.indexOf(stage) !== -1) continue;
if (due.getTime() >= startOfTomorrow.getTime()) continue;
const dueDay = new Date(due); dueDay.setHours(0, 0, 0, 0);
const owner = String(row[cols[COL.OWNER] - 1] || fallback).trim();
(byOwner[owner] = byOwner[owner] || []).push({
company: row[cols[COL.COMPANY] - 1], contact: row[cols[COL.CONTACT] - 1],
stage: stage, next: row[cols[COL.NEXT_STEP] - 1], value: row[cols[COL.VALUE] - 1],
daysLate: Math.round((startOfToday - dueDay) / 86400000), row: i + 1,
});
}
const url = ss.getUrl() + '#gid=' + deals.getSheetId();
const tz = ss.getSpreadsheetTimeZone();
Object.keys(byOwner).forEach(function (owner) {
if (MailApp.getRemainingDailyQuota() < 1) { console.warn('MailApp daily quota used up'); return; }
const items = byOwner[owner].sort(function (a, b) { return b.daysLate - a.daysLate; });
const rowsHtml = items.map(function (d) {
return '<tr><td>' + esc_(d.company) + '</td><td>' + esc_(d.contact) + '</td><td>' + esc_(d.stage) +
'</td><td>' + esc_(d.next) + '</td><td style="text-align:right">' + esc_(d.value) + '</td><td>' +
(d.daysLate === 0 ? 'today' : d.daysLate + ' day(s) late') + '</td><td>row ' + d.row + '</td></tr>';
}).join('');
const html = '<p>' + items.length + ' follow-up(s) due as of ' +
Utilities.formatDate(startOfToday, tz, 'EEE d MMM yyyy') + ':</p>' +
'<table border="1" cellpadding="6" style="border-collapse:collapse;font-family:sans-serif;font-size:13px">' +
'<tr><th>Company</th><th>Contact</th><th>Stage</th><th>Next step</th><th>Value</th><th>Due</th><th>Where</th></tr>' +
rowsHtml + '</table><p><a href="' + url + '">Open the CRM</a></p>';
const text = items.map(function (d) {
return '- ' + d.company + ' (' + d.stage + '): ' + d.next + ' [' + (d.daysLate === 0 ? 'today' : d.daysLate + 'd late') + ']';
}).join('\n') + '\n\n' + url;
MailApp.sendEmail({ to: owner, subject: 'CRM: ' + items.length + ' follow-up(s) due', body: text, htmlBody: html });
});
}
function esc_(v) {
return String(v === null || v === undefined ? '' : v)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
/* ---------- One-time setup ---------- */
function setup() {
const ss = SpreadsheetApp.getActive();
if (!ss.getSheetByName(CONFIG.LOG_SHEET)) {
ss.insertSheet(CONFIG.LOG_SHEET)
.appendRow(['Changed at', 'Deal ID', 'Company', 'From stage', 'To stage', 'Changed by']);
}
buildFollowUpsView();
addHighlighting_();
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'sendFollowUpDigest') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('sendFollowUpDigest').timeBased().everyDays(1).atHour(CONFIG.DIGEST_HOUR).create();
ss.toast('CRM set up: follow-ups view, highlighting and daily digest at ~' + CONFIG.DIGEST_HOUR + ':00.', 'CRM', 8);
}
What you give up
−No email or calendar sync: emails and meetings are not logged automatically; people must add a line to Activities or the Notes column.
−No sequences, open/click tracking or meeting-booking links — those need a CRM or a separate tool.
−Simple onEdit triggers do not fire for changes made by scripts, the Sheets API, Zapier-style integrations or form submissions, so those rows get no stage timestamp.
−Shared-sheet permissions are all-or-nothing per tab/range: anyone with edit access can see and change every deal.
−MailApp limits: 100 email recipients/day on a free Google account and 1,500/day on Google Workspace; triggers share a daily runtime quota (90 min consumer, 6 h Workspace).
−Reporting is whatever you build: pivot tables, charts or a Looker Studio dashboard — there's no forecasting engine.
−The digest's date logic uses the script's time zone; teammates in other time zones receive it at that zone's hour.
Risks
mediumfunctionality loss
You lose automatic activity capture, mobile CRM apps, email tracking and built-in reports. Follow-up discipline depends on people updating the sheet.
mediumreliability
If someone renames a header, the script stops (it shows a toast and the Executions log records the error). The digest silently stops if the trigger owner leaves the company or their account is suspended — reinstall the trigger from a shared admin account.
mediumsecurity
Customer contact details sit in a spreadsheet that can be shared or downloaded with a click. Restrict sharing to named people, disable 'Viewers and commenters can download, print and copy', and never share by link.
lowcompliance
Sheets version history is not a tamper-proof audit log, and deletion/retention requests (e.g. GDPR erasure) must be handled manually across tabs, the Stage log and version history.
lowmigration complexity
Importing from a CRM is mostly copy-paste, but activity history and attachments rarely export cleanly. Keep the final CRM export archived.
Before you cancel
Run these in parallel with your current tool. Only cancel when every box is ticked.
0/7 checks passed
Saved in this browser only. Cancel the old subscription only after every check passes.
How the savings work
Savings = (CRM price per seat per month × number of paid seats) − incremental cost of the replacement. The replacement's incremental cost is $0 because Sheets, Apps Script and MailApp come with Google accounts you already have, and an AI assistant you already pay for. We do not count setup time (≈half a day) or maintenance (≈15 min/month) as dollars; if your time is billable, subtract it. If your current plan is billed annually, savings only start at renewal unless the vendor refunds unused time.
Illustrative example
A 3-person design studio on a paid CRM plan moves its pipeline to Google Sheets.
Before$120.00/mo
After$0.00/mo
Saving$120.00/mo · $1440/yr
ILLUSTRATIVE: 3 paid CRM seats at an assumed $40 per seat per month. We could not verify current Pipedrive or HubSpot paid-plan prices on the vendors' own pages today (Pipedrive's pricing page blocked automated access; HubSpot's pricing page did not show prices) — use the amount on your own invoice.
The studio already pays for Google Workspace and an AI assistant, so nothing new is bought.
HubSpot's free CRM (up to 2 users, 1,000 contacts — verified on HubSpot's product page) would also cost $0 but doesn't fit 3 users.
Personalise this workflow
Tailors the steps and prompts to your tools, team and volume using only our verified catalog data.
Sources & freshness
HubSpot free CRM product page— accessed Oct 13, 2026 · Free with no expiration; up to two users and 1,000 contacts; includes deal pipelines and email tracking.
Apps Script simple triggers — restrictions— accessed Oct 13, 2026 · Simple triggers can't use services requiring authorisation (e.g. sending email), 30-second limit, not fired by script/API edits.
Apps Script quotas— accessed Oct 13, 2026 · Email recipients/day: 100 consumer, 1,500 Workspace; trigger runtime 90 min/day consumer, 6 h/day Workspace; 6 min per execution.
Pipedrive pricing— accessed Oct 13, 2026 · Returned HTTP 403 to our fetch; prices not verified.
HubSpot Sales Hub pricing— accessed Oct 13, 2026 · Page loaded without visible prices; paid-plan prices not verified.
Evidence: editorial — Script written for this workflow and reviewed for Apps Script trigger/quotas rules as documented today; test on a copy before using on live data. Paid CRM prices in the example are illustrative. · Machine-readable: JSON · Markdown