Automation
From 4 Hours to 5 Minutes: Rebuilding End-of-Month Reporting with n8n
A build log from a GoHighLevel agency: how an n8n ETL pipeline replaced a half-day manual reporting process with a fully automated workflow that runs itself on the last business day of every month.
The problem
Every end-of-month, the same half-day disappeared.
An ops team member would open five GoHighLevel dashboards, manually pull contact counts, pipeline values, appointment completions, and campaign stats for each client, paste them into a spreadsheet, do the cross-client reconciliation by hand, format the final tables, and send the reports. Four-plus hours of mechanical work. Twelve months a year. As the agency's client roster grew, this was going to become a two-person job.
The work wasn't complicated — it was just repetitive and tedious. Everything the human was doing was rule-based. GHL already had the data. The spreadsheet format was fixed. The math was always the same. This was exactly the shape of a problem that automation should handle.
What I built
A multi-stage n8n workflow that:
- Triggers automatically on the last business day of each month
- Pulls all client performance data from GoHighLevel via API — contacts, pipelines, appointments, campaign stats
- Normalises and aggregates the data across all clients into a consistent shape
- Populates a report template with the aggregated numbers
- Delivers the report via email, with a backup copy written to a shared Drive folder
No human involvement required. From trigger to delivered report: under 5 minutes.
The trigger problem
The first thing to solve: when does "last business day of the month" actually run?
n8n supports cron schedules, but "last business day of the month" isn't a standard cron
expression. The cleanest approach: run on the last three calendar days of every month
(28, 29, 30, 31 * * 1-5 — weekdays only), then add a code node at the start of the
workflow that checks whether tomorrow is still in the current month.
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isLastBusinessDay = tomorrow.getMonth() !== now.getMonth();
if (!isLastBusinessDay) {
return []; // Exit workflow early
}If tomorrow is in a different month, we're on the last weekday. If not, the workflow exits immediately with no action. Simple, reliable, and doesn't require a custom cron library.
The GHL data pull
GoHighLevel exposes its data through a REST API with location-scoped OAuth tokens.
For a multi-client agency, each client is a GHL sub-account with its own locationId
and API credentials. The workflow starts with a list of all active client locations —
stored as a JSON config node — and then uses n8n's SplitInBatches node to process
each client sequentially.
For each client, four API calls run in sequence:
GET /contacts?locationId={id}&page=1&limit=100
GET /opportunities?locationId={id}&pipelineId={pipelineId}
GET /appointments?locationId={id}&startDate={firstOfMonth}&endDate={lastOfMonth}
GET /campaigns/{campaignId}/stats
The contact and opportunity endpoints paginate. A loop node handles this:
keep calling with page++ until the response contains fewer results than limit.
All pages merge into a single array before the aggregation step.
The normalisation layer
Raw GHL data isn't report-ready. Contacts include test records, internal users, and archived contacts that skew the numbers. Opportunities include closed-lost deals that inflate pipeline value unless you're specifically reporting on them.
A Code node in n8n handles normalisation before anything gets aggregated:
const contacts = $input.all().filter(c =>
c.json.tags && !c.json.tags.includes('internal') &&
c.json.dateAdded >= firstOfMonth
);
const activeOpportunities = opportunities.filter(o =>
['open', 'won'].includes(o.json.status)
);
const completedAppointments = appointments.filter(a =>
a.json.status === 'completed'
);The same filter logic applies to every client. If a client has a specific tagging convention that needs different handling, that's a config value, not a code change.
Populating the report template
The template lives in Google Sheets — one tab per client, a summary tab that auto-calculates. n8n's Google Sheets node updates specific cells by range notation:
Sheet: "ClientName"
Range: B2:B8
Values: [newContacts, activeOpps, oppValue, completedAppts, ...]
The summary tab uses =SUM() formulas that reference the individual client tabs,
so cross-client totals update automatically when the individual sheets are written.
No code needed for the rollup math.
For the email delivery, a Send Email node uses an HTML template with {{ }} variable
substitution for the key metrics. The GSheet link is attached. The email goes to each
client's designated contact, pulled from the client config at the start.
Error handling
The workflow has three types of failure:
API rate limits. GHL has per-minute request limits. The SplitInBatches node
runs clients sequentially with a 500ms delay between batches, which stays well inside
limits even for a 20-client roster. If a 429 error occurs, n8n's retry-on-error
setting handles it automatically with exponential backoff.
Missing data. Some clients may have no appointments or no active campaigns.
Every metric is wrapped in a null-safe calculation — appointments?.length ?? 0 —
so missing data produces a zero rather than crashing the workflow.
Delivery failure. If the email send fails, the workflow catches the error with
an Error Trigger node that fires a Slack alert to the ops channel with the specific
client name and the error message. Manual recovery is possible because the data is
already written to the Sheet.
Results
The first full cycle ran without intervention. Report generation went from four-plus hours to under five minutes. The ops team's end-of-month no longer has a reporting block — those hours go to client strategy work instead.
The workflow has run for every monthly cycle since deployment with no manual intervention required. One edge case surfaced in month two: a client changed their campaign ID, which caused a 404 on the campaign stats pull. The error caught it, fired a Slack alert, and the config was updated in 10 minutes. No report was delayed.
What I'd do differently
Config as a separate data source. The client config list is currently a JSON node inside the n8n workflow. That's fine until someone needs to update it outside of n8n — an account manager adding a new client, for example. Moving config to a Google Sheet or a simple Airtable base means non-technical team members can maintain the client list without touching the workflow.
Per-client delta tracking. Right now the report shows absolute numbers for the
month. Adding a comparison to the prior month would require storing last month's
numbers somewhere. Upstash Redis is the obvious choice — lightweight, cheap, and
accessible from n8n's HTTP Request node with a simple GET/SET pattern.
Related Reading
Build Log
The AI Peak Myth: Why Your Ops Should Be Doubling Down on Automation Now
Reacting to the 'AI peak' narrative with evidence from the battlefield and a playbook for ops leaders to keep investing in automation.
Build Log
The n8n + AI Agents Freelancer Boom: How Agency Owners Can Hire, Sell, and Scope This Trend Without Getting Burned
r/n8n is buzzing about AI agents and freelancing. Here's how agency owners can turn that trend into a scoped, sellable service.