How to Track Membership Renewals in Google Sheets and Email Reminders Before They Expire (2026)
Clubs, associations, gyms, studios, alumni groups and nonprofits often keep their member list in a spreadsheet, and the renewal chase usually happens by hand: someone scrolls the list, spots who's about to expire, and writes the emails one by one, or forgets to. This guide turns a member list in Google Sheets into a renewal tracker. It works out each expiry date from the term, shows who is Active, in a grace period or Lapsed, lists who expires in the next 30 days, and adds a free Apps Script that emails each member 30 days, 7 days and on the day their membership expires, plus one optional “come back” note two weeks after.
The difference from the other reminder guides here is who gets the email. Contract renewal alerts and the warranty tracker remind you. This one writes to the members themselves, one personal email each, so it starts in a preview mode that sends everything to you first.
Step 1: a Members tab
Create a tab named Members with these headers in row 1. The script finds columns by name, but the formulas below assume this order (A to L):
| Col | Header | What goes in it |
|---|---|---|
| A | Name | The name you'd use in “Hi …”. Use first names here if that's how you write to members |
| B | One address per member | |
| C | Plan | e.g. Single, Family, Student |
| D | Joined | The date they first joined (kept for your records; the script doesn't use it) |
| E | Term start | The start of the current paid term |
| F | Term months | Length of the term, e.g. 12 for annual or 1 for monthly |
| G | Expires | A formula (Step 2), formatted as a date |
| H | Days left | A formula (Step 2) |
| I | Standing | A formula (Step 2): Active, Grace period or Lapsed |
| J | Status | Leave blank for normal members. Cancelled, Lifetime, Honorary or Do not email stops reminders |
| K | Renew link | Optional: a personal renewal or payment link for this member. Blank uses one link for everyone |
| L | Sent log | Leave empty; the script records what it sent |
Format D, E and G as dates (Format › Number › Date). A date typed as text, like “Jan 11”, won't calculate.
Step 2: expiry date, days left and standing
In G2, fill down. EDATE adds whole months and handles short months (a 1-month term starting January 31 ends February 28):
=IF(OR(E2="",F2=""),"",EDATE(E2,F2))
That ends the term on the same day of the month it started. If your terms run “through” the day before (January 1 to December 31), use EDATE(E2,F2)-1. If everyone expires on the same date, like a calendar-year membership, use =IF(E2="","",DATE(YEAR(E2),12,31)) instead.
In H2, the days remaining (format it as a plain number; negative means it has expired):
=IF(G2="","",G2-TODAY())
In I2, a readable standing with a 30-day grace period (change the 30 to match your rules). A Status like Lifetime or Cancelled shows instead:
=IF(J2<>"",J2,IF(G2="","",IF(G2>=TODAY(),"Active",IF(TODAY()-G2<=30,"Grace period","Lapsed"))))
TODAY() recalculates every day, so Days left and Standing update on their own.
Step 3: who expires soon, and a quick count
On a new tab, this lists every member whose membership expires in the next 30 days, soonest first, skipping anyone with a Status set:
=SORT(FILTER({Members!A2:A,Members!B2:B,Members!C2:C,Members!G2:G,Members!H2:H},
Members!G2:G<>"",Members!H2:H>=0,Members!H2:H<=30,Members!J2:J=""),5,TRUE)
A FILTER that finds nothing shows #N/A; wrap it in IFERROR(…,"No one expiring") if you like. Format the fourth column as a date. For a dashboard, count members by standing and by plan:
=COUNTIF(Members!I2:I,"Active") =COUNTIF(Members!I2:I,"Grace period") =QUERY(Members!A2:L,"select C, count(A) where I = 'Active' group by C label count(A) 'Active members'",0)
Step 4: color the list
Select A2:L, open Format › Conditional formatting, pick Custom formula is, and add these rules (the $ keeps each rule on column H and I while it colors the whole row):
| Custom formula | Color | Meaning |
|---|---|---|
=AND(ISNUMBER($H2),$H2>=0,$H2<=30,$J2="") | Yellow | Expires within 30 days |
=$I2="Grace period" | Orange | Expired, still in the grace period |
=$I2="Lapsed" | Gray | Lapsed |
Step 5: email members before they expire
Sheets can't send an email when a date arrives on its own; its notification rules react to edits, not the calendar. A short Apps Script on a daily trigger does it. Open Extensions › Apps Script, paste this, and set ORG_NAME and RENEW_URL. Leave PREVIEW = true, save, and run sendMembershipReminders once to approve permissions. Every email that would go out today lands in your own inbox, with the real recipient in the subject, and nothing is logged. When the previews look right, set PREVIEW = false and add a trigger: Triggers › Add trigger › sendMembershipReminders › Time-driven › Day timer.
/**
* Membership renewal reminders from a "Members" tab, emailed to each member.
* Headers used (any order): Name, Email, Expires, Sent log.
* Optional: Status (stops reminders), Renew link (per-member link),
* and any other column you name in the messages as {{Column name}}.
*/
const SHEET_NAME = 'Members';
const ORG_NAME = 'Riverside Running Club';
const RENEW_URL = 'https://example.org/renew'; // used when a row has no Renew link
const REPLY_TO = ''; // e.g. 'membership@yourclub.org' (blank = you)
const REMIND_DAYS = [30, 7, 0]; // days before Expires
const LAPSED_DAYS = 14; // one "come back" note this many days after; 0 = off
const STOP_STATUS = /^(cancelled|canceled|lifetime|honorary|do not email)$/i;
const PREVIEW = true; // true: everything goes to you, nothing is logged
const MAX_EMAILS = 50; // safety cap per run
const SUBJECT_SOON = 'Your {{Org}} membership expires {{When}}';
const BODY_SOON =
'Hi {{Name}},\n\nYour {{Org}} membership expires {{When}} ({{Expires on}}). ' +
'To keep it going, you can renew here:\n{{Renew link}}\n\n' +
"If you've already renewed, thank you, and please ignore this note.\n\n{{Org}}";
const SUBJECT_LAPSED = 'Your {{Org}} membership has expired';
const BODY_LAPSED =
'Hi {{Name}},\n\nYour {{Org}} membership ended on {{Expires on}}. ' +
"We'd love to have you back. You can renew any time here:\n{{Renew link}}\n\n" +
"If you'd rather not hear from us about this again, just reply and say so.\n\n{{Org}}";
function sendMembershipReminders(now) {
const lock = LockService.getDocumentLock();
if (!lock.tryLock(30000)) return; // another run is still going
try {
// A time-driven trigger passes an event object, so only use a real Date.
return remindMembers_(now instanceof Date ? new Date(now) : new Date());
} finally {
lock.releaseLock();
}
}
function remindMembers_(today) {
const sh = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
if (!sh) throw new Error(`No tab named ${SHEET_NAME}.`);
const range = sh.getDataRange();
const rows = range.getValues(), shown = range.getDisplayValues();
const head = rows[0].map((h) => String(h).trim());
const col = (name) => head.indexOf(name);
const c = { name: col('Name'), email: col('Email'), exp: col('Expires'),
status: col('Status'), link: col('Renew link'), log: col('Sent log') };
if ([c.name, c.email, c.exp, c.log].includes(-1)) {
throw new Error('Need Name, Email, Expires and Sent log columns.');
}
// Check every {{placeholder}} before anything is sent.
const extra = ['Org', 'When', 'Expires on', 'Renew link'];
const tags = [SUBJECT_SOON, BODY_SOON, SUBJECT_LAPSED, BODY_LAPSED].join(' ')
.match(/{{[^{}]+}}/g) || [];
const unknown = [...new Set(tags.map((t) => t.slice(2, -2).trim()))]
.filter((k) => !head.includes(k) && !extra.includes(k));
if (unknown.length) {
throw new Error(`No column for: ${unknown.map((k) => `{{${k}}}`).join(', ')}. Nothing was sent.`);
}
const tz = Session.getScriptTimeZone();
today.setHours(0, 0, 0, 0);
const isDate = (v) => v instanceof Date && !isNaN(v);
const ymd = (d) => Utilities.formatDate(d, tz, 'yyyy-MM-dd');
const nice = (d) => Utilities.formatDate(d, tz, 'MMMM d, yyyy');
const daysTo = (d) => {
const x = new Date(d); x.setHours(0, 0, 0, 0);
return Math.round((x - today) / 86400000);
};
const when = (n) => (n === 0 ? 'today' : n === 1 ? 'tomorrow' : `in ${n} days`);
const EMAIL = /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/;
// Values go in literally: a "$1" or "{{x}}" inside a cell is not re-filled.
const fill = (tpl, vals) => tpl.replace(/{{([^{}]+)}}/g, (_, k) => vals[k.trim()]);
const due = [];
let badEmail = 0;
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
if (c.status !== -1 && STOP_STATUS.test(String(row[c.status]).trim())) continue;
const exp = row[c.exp];
if (!isDate(exp)) continue;
const left = daysTo(exp);
let hit;
if (left >= 0) {
// The one reminder that fits today: no pile of earlier ones after a gap.
hit = REMIND_DAYS.filter((n) => left <= n).sort((a, b) => a - b)[0];
} else if (LAPSED_DAYS > 0 && -left >= LAPSED_DAYS && -left < LAPSED_DAYS + 7) {
// A one-week window, so long-lapsed members don't all get mail on day one.
hit = -LAPSED_DAYS;
}
if (hit === undefined) continue;
const sent = String(row[c.log] || '').split(';').filter(String);
const key = `M${ymd(exp)}@${hit}`;
if (sent.includes(key)) continue;
const to = String(row[c.email]).trim();
if (!EMAIL.test(to)) { badEmail++; console.log(`Row ${r + 1}: check the email address`); continue; }
const vals = {};
head.forEach((h, i) => { if (h) vals[h] = shown[r][i]; });
Object.assign(vals, {
Org: ORG_NAME, When: when(left), 'Expires on': nice(exp),
'Renew link': String(c.link === -1 ? '' : shown[r][c.link]).trim() || RENEW_URL,
});
due.push({ r: r + 1, to, left, lapsed: hit < 0, vals,
log: sent.concat(key).slice(-9).join(';') });
}
// Soonest expiry first, lapsed notes last, in case the quota runs out.
due.sort((a, b) => (a.lapsed - b.lapsed) || (a.left - b.left));
const me = Session.getEffectiveUser().getEmail();
let sent = 0;
for (const d of due) {
if (sent >= MAX_EMAILS || MailApp.getRemainingDailyQuota() < 1) break;
const subject = fill(d.lapsed ? SUBJECT_LAPSED : SUBJECT_SOON, d.vals);
const msg = {
to: PREVIEW ? me : d.to,
subject: PREVIEW ? `[PREVIEW to ${d.to}] ${subject}` : subject,
body: fill(d.lapsed ? BODY_LAPSED : BODY_SOON, d.vals),
name: ORG_NAME,
};
if (REPLY_TO) msg.replyTo = REPLY_TO;
MailApp.sendEmail(msg);
sent++;
if (!PREVIEW) {
// Log right after each email, so a crash can't cause a repeat.
sh.getRange(d.r, c.log + 1).setValue(d.log);
SpreadsheetApp.flush();
}
}
const result = { sent, left: due.length - sent, badEmail, preview: PREVIEW };
console.log(JSON.stringify(result));
return result;
}
How it behaves:
- One personal email per member. It writes to the address in Email, using the subject and message at the top of the script. Any column header works as a placeholder, like
{{Plan}}, plus{{Org}},{{When}}(“in 7 days”, “tomorrow”, “today”),{{Expires on}}and{{Renew link}}. Values go in exactly as the sheet shows them, and a placeholder with no matching column stops the run before anything is sent. - 30 days, 7 days and the day of. Change
REMIND_DAYSto suit, like[60, 14, 0]. It reads the Expires date directly, so it doesn't depend on the Days left or Standing formulas. - No catch-up pile. If the trigger starts late or misses a few days, a member gets the one reminder that fits (say, the 30-day one at 17 days left), not all the earlier ones.
- One “come back” note, then silence.
LAPSED_DAYS = 14sends a single note two weeks after expiry. It only goes out in the week after that point, so members who lapsed months ago don't all get an email the day you install the script. Set it to 0 to turn it off. - Renewals reset on their own. Sent log entries include the expiry date they were for (like
M2026-12-13@30). When a member renews and you change Term start, the new Expires date gets its own reminders. - Each reminder goes out once. The log entry is written right after that member's email goes out. Reruns the same day send nothing, and two overlapping runs can't both send, because the script takes a document lock.
- Stops when it should. A Status of Cancelled, Lifetime, Honorary or Do not email skips the row. Rows without a valid date or with a malformed address are skipped too (the address problems show in the Apps Script execution log).
- Quota-aware. It checks
MailApp.getRemainingDailyQuota()before each email, sends the most urgent first, and stops atMAX_EMAILSper run. Anything left over goes out on the next run.
We mock-tested this logic before publishing (30 checks): preview mode sent only to the owner and logged nothing; the real run sent today's, 7-day, late 30-day and 30-day reminders in that order, then the lapsed note; reruns the same day and the next day sent nothing; the day-of reminder followed the 7-day one; a renewed member got a fresh reminder for the new date; cancelled, lifetime, long-lapsed and malformed-address rows were skipped; values like $1 went in literally; an unknown placeholder stopped the run with nothing sent; and the quota and per-run caps left the rest for the next run without logging them.
Recording a renewal
- Set Term start to the old expiry date, not the payment date, if you want early renewers to keep the days they had left. A member who expires December 13 and pays on December 1 then runs to the next December 13. If your rules start a new term on the payment date, enter that instead.
- Changing plans? Update Plan and Term months in the same row. Keep one row per member so the reminders and the log follow them.
- Keep history somewhere else. If you need a record of every payment, add a Payments tab (Name, Email, Paid on, Amount, Term start) and update the Members row when a payment comes in. A receipt per payment can be generated from that tab.
Tips
- Preview again after edits. Any time you change the message or the columns, set
PREVIEW = trueand run it once by hand before the trigger sends anything for real. - Respect “no”. When someone asks not to get renewal emails, set their Status to Do not email. Keep these emails about their membership; newsletters and promotions belong in a proper email tool with an unsubscribe link.
- Match time zones. Set the script's time zone (Project Settings in Apps Script) to the same zone as the spreadsheet (File › Settings), or a reminder can land a day off.
- Who it comes from. The trigger runs as whoever created it, and the emails come from that Google account, with
ORG_NAMEas the sender name. SetREPLY_TOto a shared membership address so replies reach the right people. - Quotas. Apps Script limits email recipients per day: 100 for consumer Gmail accounts and 1,500 for Google Workspace accounts, according to Google's Apps Script quotas page. With three reminders per member per year, that covers a large club, but a big batch of members who all joined on the same day can hit it; the script then finishes on the next run.
- Protect the list. Member lists hold names, emails and sometimes payment notes. Share the sheet only with the people who run membership.
- Membership cards or welcome letters? To make a PDF for each new member from the same rows, see a PDF or Google Doc for each row.
See SheetReminders
Related Google Sheets guides
- How to generate a PDF or Google Doc for each row in Google Sheets
- Best Autocrat alternatives for mail merge to PDF and Docs
- Make invoices or certificates from Google Sheets rows
- Create sales quotes or estimates from Google Sheets
- Generate receipts from Google Sheets rows
- Create purchase orders from Google Sheets
- Make packing slips from Google Sheets orders
- Generate a PDF certificate from a Google Form response
- Issue numbered training completion certificates with expiry dates
- Make a PDF progress report for each student
- Generate offer letters from a Google Sheets hiring tracker
- Email each row’s PDF as an attachment
- Automatic email reminders from a date column
- Contract renewal and expiration alerts from Google Sheets
- Track certificate of insurance (COI) expirations in Google Sheets
- Track employee license and certification expirations
- Track equipment and vehicle maintenance with reminders
- Track warranty expiration dates with reminders
- Birthday and work anniversary reminders from Google Sheets
- Rent due and lease renewal reminders from Google Sheets
- Send invoice payment reminders from Google Sheets
- Highlight overdue tasks and send email reminders
- Follow-up reminders for leads, clients and candidates
- Membership renewal reminders for clubs and associations
- SheetWeave vs Autocrat
- SheetWeave vs Document Studio
- SheetWeave vs RowDocs
Instructions refer to Google Sheets and Apps Script as they worked on November 13, 2026. Not affiliated with Google or any other vendor named here. Google Sheets and Gmail are trademarks of Google LLC. Features change, so verify in your own account. Questions or corrections: hello@loomwise.app.