The pre-call brief: what to know before every customer meeting, and how to send it automatically
8 min read (+ optional code)
By Tim, founder of Seamline · Last updated 9 October 2026
Anyone who has worked in customer success knows the ritual. Before every call you open the billing tool: what are they paying, is anything overdue, which discount did we give them? Then the CRM: notes, deals, who spoke to them last. Then the support desk: open tickets, the same complaint twice. Then the contract, because nobody remembers the notice period. Then Slack, to check whether a colleague talked to them this week.
Five systems, ten to fifteen minutes, for one question: what's actually going on with this customer? Multiply that by every meeting in a week.
And even after checking all five, some questions stay open. What this customer is really worth after discounts. Whether they're drifting away. Why the last one like them left. Those answers don't live in any one system. They only exist once the systems are connected.
This use case builds the connected version of that ritual: a five-line brief that arrives in Slack the day before every customer meeting, with every line pointing to the system it came from. It's built with n8n, a small Postgres database and the tools you already use, and it was tested end to end on sample data. You can read it in about eight minutes; the code sits behind toggles.
All companies, people and numbers in this use case are fictional.
The result
The day before the meeting, the account owner gets this in Slack:
Northwind Analytics · Northwind: monthly check-in · tomorrow 10:00 • Money: €1,950/mo billed, €2,400/mo in the contract (€450/mo less: a discount, a seat change or a billing error). Invoice NW-2026-0412 (€1,950) overdue 17 days. → Stripe, HubSpot • Support: 7 open tickets, 1 urgent: "SSO login fails for EU users", no reply for 2 days. 3 of 7 are tagged "export". → Zendesk • Usage: 27 daily active users on average over the last 30 days, down 35% from the 30 days before. → product data • Contract: renews 18 Dec, in 64 days. Notice deadline (last day to cancel) 19 Oct, in 4 days. → HubSpot • Last contact: last meeting 71 days ago (Sanne de Vries). Joris Bakker (AE), 5 days ago: "Asked what it costs to go from 40 to 25 seats." → HubSpot Before you call: They have 4 days left to give notice. Ask about their renewal plans, and start with the urgent ticket.
Each line on its own is a small thing. Together they change the meeting. What looked like a monthly check-in is really about the renewal: the customer has four days left to give notice, an urgent ticket is unanswered, usage is falling, and they just asked a sales colleague about fewer seats. No single system shows that. Each one shows a fifth of it.
And when the data isn't there, the brief says so instead of guessing:
Vanderberg Mobility · Vanderberg: onboarding review · tomorrow 14:00 • Money: unknown: billing not linked to this customer yet • Support: unknown: support not linked to this customer yet • Usage: unknown: product usage not linked to this customer yet • Contract: renews 1 Mar, in 137 days. → HubSpot • Last contact: last meeting 23 days ago (Sanne de Vries). → HubSpot Before you call: Parts of this customer aren't linked yet (billing, support, product usage). Ask RevOps to confirm the links, or check those systems yourself.
An empty line that looks fine is worse than no brief at all. "Unknown" tells the owner exactly which tabs they still need to open.
The five lines
| Line | The question it answers | Where it comes from | Worth noticing when |
|---|---|---|---|
| Money | Are they paying what was agreed, on time? | Billing (Stripe), plus the contract value in the CRM | An invoice is overdue, or billing differs from the contract |
| Support | Are they stuck or frustrated right now? | Support desk (Zendesk) | An urgent ticket is waiting, or one topic keeps coming back |
| Usage | Are they still getting value? | Your product database | Average daily active users fall 20% or more |
| Contract | How much time is left, and when can they leave? | CRM fields (HubSpot) | The notice deadline is within 30 days |
| Last contact | Who spoke to them, and what was said? | CRM activities (HubSpot) | No meeting for 60+ days, or a colleague logged something new |
Plus one sentence at the end, "Before you call", chosen by simple rules from the flags above.
Two of these lines need a definition before they mean anything:
- Billed vs contract. The contract value here is the monthly amount agreed in the current contract, stored in the CRM. If billing differs, the brief shows both numbers and doesn't call it an error. A discount, a seat change or a billing mistake all look the same until someone checks.
- Active users. Here, an active user is a person who used the product on a given day, as counted in the product's own database. The brief compares the average per day over the last 30 days with the 30 days before. Whatever definition you pick, keep it the same every month.
What to leave out
A brief is useful because it's short. Leave out:
- Full ticket and email history. The count, the urgent one and the recurring topic are enough. The rest is one click away.
- Every metric you have. One usage number, compared with last month. Not a dashboard.
- A health score without its reasons. If you use one, show the signals behind it, as in the customer health score use case.
- Anything that didn't change. A paid invoice, a closed ticket and a quiet month don't need a line of their own.
Why AI alone doesn't fix this
It's tempting to connect an AI assistant to the CRM and ask "brief me on Northwind". It will answer, fluently. But it can only use what it can see, and in most setups that's the CRM: the notes and the deal, not the overdue invoice, the urgent ticket or the falling usage. You get a confident summary of a fifth of the picture.
The hard part isn't writing the brief. It's knowing that cus_N1 in Stripe, organisation 501 in Zendesk and account acc_7 in the product are all Northwind, and having their data in one place. Once that's true, a few rules write the brief, as shown below. AI can come later, for summarising long notes or suggesting a first question, working from the same linked facts.
How it works: n8n, Postgres and Slack
Stripe ─────┐
Zendesk ────┤ Sync workflow (n8n, every 30 minutes)
HubSpot ────┘ │
▼
Product DB ───► Customer layer (Postgres)
one ID per customer, linked to every system,
plus a copy of the data the brief needs
│
Google Calendar ──► Brief workflow (n8n, every hour)
1. meetings in the next 24h with outside guests
2. which customer is it?
3. collect the facts
4. write five lines
5. send to the owner in Slack, onceTwo workflows, one small database. Nothing replaces your CRM, and every source stays the system of record for its own data.
Step 1: Establish the customer links
Every system has its own ID for the same customer. Store the links in one table, with a status, and only use confirmed ones:
| Customer | HubSpot | Stripe | Zendesk | Product |
|---|---|---|---|---|
| Northwind Analytics | hs_101 | cus_N1 | org_501 | acc_7 |
| Vanderberg Mobility | hs_204 | cus_V9 (proposed, not confirmed) | — | — |
That second row is why Vanderberg's brief says "unknown". A rule suggested the Stripe customer, but nobody confirmed it, so it doesn't count. A record without a confirmed link never borrows data from another customer. Links also aren't permanent: when a record is merged, moved or linked wrongly, the link is changed, and the next brief follows. How to make and confirm these links is covered in the deduplication use case.
For the calendar, add one more small table: the email domains known to belong to each customer. That's how a meeting with j.peters@northwind.io becomes a meeting with Northwind. Only domains someone has checked go in; when a meeting's guests match more than one customer, it's flagged instead of assigned.
Step 2: Sync the sources into one place
The first n8n workflow runs every 30 minutes. For each source it calls the API, flattens the response and saves it in Postgres:
| Source | What it fetches | Stored as |
|---|---|---|
| Stripe | Subscriptions, and invoices with status open | Monthly revenue per subscription; open invoices with due date |
| Zendesk | Tickets that aren't solved | Status, priority, tags, last agent reply |
| HubSpot | Companies, with contract value, renewal date and notice period | One row per company |
| HubSpot | Notes, calls and meetings, with their company | Who logged what, and when |
Product usage usually already lives in your own database, so it doesn't need syncing.
Why copy the data instead of calling each API when a brief is due? Three reasons. A query across one database takes milliseconds; five API calls per meeting don't, and they run into rate limits. You keep history, so "down 35% compared with last month" is possible at all. And the facts become plain SQL, which anyone can check.
The sync also handles what disappears. An invoice that is no longer open in Stripe has been paid, so it's marked closed. A ticket that drops out of the unsolved search is marked solved. Without that step, the brief would keep reporting problems that were fixed last week.
If a sync fails, n8n marks the run as failed and the copy keeps its last values, so the brief describes the last successful sync. That's why it's worth storing when each source last synced, and alerting someone when it's more than a few hours old.
Product usage isn't part of the sync at all: the brief reads it straight from the product database, so it's as fresh as the product writes it.
Step 3: Turn the facts into five lines
One SQL function collects everything the brief needs about one customer, from every linked system: billed and contracted revenue, overdue invoices, open and urgent tickets, the most common ticket tag, usage this month against last month, renewal and notice dates, and the latest activities. If a source isn't linked, its facts come back empty, and the function lists it as missing.
Show the SQL for step 3
-- Everything a pre-call brief needs about one customer, from every linked system.
-- Only confirmed links count. A source that isn't linked comes back as NULL: unknown, not fine.
CREATE FUNCTION pre_call_brief(p_customer text, p_as_of date)
RETURNS TABLE (
customer text, missing_sources text[],
billed_mrr numeric, contract_mrr numeric,
overdue_count int, overdue_eur numeric, overdue_days int, overdue_number text,
open_tickets int, urgent_tickets int, urgent_subject text, urgent_waiting_days int,
top_tag text, top_tag_count int,
avg_daily_active_users int, usage_change_pct int,
renewal_date date, days_to_renewal int, notice_deadline date, days_to_notice int,
last_meeting_days int, last_meeting_by text,
latest_note_days int, latest_note_by text, latest_note text
)
LANGUAGE sql STABLE AS $$
WITH ids AS (
SELECT source, source_id FROM customer_source_ids
WHERE customer_id = p_customer AND status = 'confirmed'
),
billing AS (
SELECT sum(mrr_eur) AS mrr FROM stripe_subscriptions
WHERE customer IN (SELECT source_id FROM ids WHERE source = 'stripe') AND status IN ('active', 'past_due')
),
overdue AS (
SELECT count(*)::int AS n, sum(amount_due_eur) AS eur,
max(p_as_of - due_date)::int AS days,
(array_agg(number ORDER BY due_date))[1] AS first_number
FROM stripe_invoices
WHERE customer IN (SELECT source_id FROM ids WHERE source = 'stripe')
AND status = 'open' AND due_date < p_as_of
),
tickets AS (
SELECT * FROM zendesk_tickets
WHERE organization_id IN (SELECT source_id FROM ids WHERE source = 'zendesk')
AND status IN ('new', 'open', 'pending')
),
urgent AS (
SELECT subject, (p_as_of - coalesce(last_agent_reply_at, created_at)::date)::int AS waiting
FROM tickets WHERE priority = 'urgent'
ORDER BY coalesce(last_agent_reply_at, created_at) LIMIT 1
),
tag AS (
SELECT t AS tag, count(*)::int AS n FROM tickets, unnest(tags) t
GROUP BY t ORDER BY count(*) DESC, t LIMIT 1
),
usage AS (
SELECT avg(active_users) FILTER (WHERE day >= p_as_of - 30) AS last30,
avg(active_users) FILTER (WHERE day < p_as_of - 30 AND day >= p_as_of - 60) AS prev30
FROM product_usage_daily
WHERE account_id IN (SELECT source_id FROM ids WHERE source = 'product')
AND day < p_as_of AND day >= p_as_of - 60
),
company AS (
SELECT * FROM hubspot_companies WHERE id IN (SELECT source_id FROM ids WHERE source = 'hubspot')
),
notes AS (
SELECT * FROM hubspot_activities WHERE company_id IN (SELECT source_id FROM ids WHERE source = 'hubspot')
),
last_meeting AS (
SELECT occurred_at, logged_by FROM notes WHERE type = 'meeting' ORDER BY occurred_at DESC LIMIT 1
),
latest_note AS (
SELECT occurred_at, logged_by, summary FROM notes ORDER BY occurred_at DESC LIMIT 1
)
SELECT
c.name,
ARRAY(SELECT s FROM unnest(ARRAY['hubspot', 'stripe', 'zendesk', 'product']) s
WHERE s NOT IN (SELECT source FROM ids)),
(SELECT mrr FROM billing),
(SELECT contract_mrr_eur FROM company),
(SELECT n FROM overdue), (SELECT eur FROM overdue), (SELECT days FROM overdue), (SELECT first_number FROM overdue),
CASE WHEN EXISTS (SELECT 1 FROM ids WHERE source = 'zendesk') THEN (SELECT count(*)::int FROM tickets) END,
CASE WHEN EXISTS (SELECT 1 FROM ids WHERE source = 'zendesk')
THEN (SELECT count(*)::int FROM tickets WHERE priority = 'urgent') END,
(SELECT subject FROM urgent), (SELECT waiting FROM urgent),
(SELECT tag FROM tag), (SELECT n FROM tag),
(SELECT round(last30)::int FROM usage),
(SELECT round(100 * (last30 - prev30) / nullif(prev30, 0))::int FROM usage),
(SELECT renewal_date FROM company),
(SELECT renewal_date - p_as_of FROM company),
(SELECT renewal_date - notice_days FROM company),
(SELECT (renewal_date - notice_days) - p_as_of FROM company),
(SELECT (p_as_of - occurred_at::date)::int FROM last_meeting), (SELECT logged_by FROM last_meeting),
(SELECT (p_as_of - occurred_at::date)::int FROM latest_note), (SELECT logged_by FROM latest_note),
(SELECT summary FROM latest_note)
FROM customers c
WHERE c.customer_id = p_customer;
$$;Then a short piece of code in n8n turns the facts into sentences. There's no AI in it, on purpose: every line is a fact with a source, and the closing suggestion comes from four rules.
| If | The brief suggests |
|---|---|
| A source isn't linked | Which systems to check by hand, and who should fix the links |
| The notice deadline is within 30 days | Ask about their renewal plans |
| An urgent ticket is waiting | Start with the ticket |
| An invoice is overdue | Mention it, after the agenda |
Show the code that writes the brief
The JavaScript in the n8n Code node:
// Turns the facts into five short lines. Rules, not AI: every line is a fact with a source.
const eur = n => '€' + Math.round(Number(n)).toLocaleString('en-US');
const days = n => n === 1 ? '1 day' : `${n} days`;
const unknown = src => `_unknown: ${src} not linked to this customer yet_`;
const end = s => /[.!?]["”]?$/.test(s) ? s : s + '.';
const label = { stripe: 'billing', zendesk: 'support', product: 'product usage', hubspot: 'CRM' };
const out = [];
for (const [i, { json: f }] of $input.all().entries()) {
const missing = new Set(f.missing_sources ?? []);
const time = new Date(f.start).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Amsterdam' });
const lines = [];
const flags = [];
// Guests from more than one customer: say so instead of picking one.
if (f.matches > 1) {
const text = `*${f.title}* · tomorrow ${time}\nGuests from ${f.matches} customers are invited, so no brief was written. Check which account this meeting is about.`;
out.push({ json: { meetingId: f.meeting_id, customer: null, text }, pairedItem: { item: i } });
continue;
}
// 1. Money
if (missing.has('stripe')) lines.push(`*Money:* ${unknown('billing')}`);
else {
let m = `${eur(f.billed_mrr)}/mo billed`;
if (f.contract_mrr && Number(f.contract_mrr) !== Number(f.billed_mrr)) {
// Show the difference; don't call it an error. It can be a discount, a seat change or a mistake.
const gap = Number(f.billed_mrr) - Number(f.contract_mrr);
m += `, ${eur(f.contract_mrr)}/mo in the contract (${eur(Math.abs(gap))}/mo ${gap < 0 ? 'less' : 'more'}: a discount, a seat change or a billing error)`;
}
if (f.overdue_count > 0) {
m += `. Invoice ${f.overdue_number} (${eur(f.overdue_eur)}) overdue ${days(f.overdue_days)}`;
flags.push('overdue');
} else m += '. No overdue invoices';
lines.push(`*Money:* ${m}. → Stripe, HubSpot`);
}
// 2. Support
if (missing.has('zendesk')) lines.push(`*Support:* ${unknown('support')}`);
else if (f.open_tickets === 0) lines.push('*Support:* no open tickets. → Zendesk');
else {
let s = `${f.open_tickets} open ticket${f.open_tickets === 1 ? '' : 's'}`;
if (f.urgent_tickets > 0) {
s += `, ${f.urgent_tickets} urgent: "${f.urgent_subject}", no reply for ${days(f.urgent_waiting_days)}`;
flags.push('urgent');
}
if (f.top_tag_count >= 3) s += `. ${f.top_tag_count} of ${f.open_tickets} are tagged "${f.top_tag}"`;
lines.push(`*Support:* ${s}. → Zendesk`);
}
// 3. Usage
if (missing.has('product')) lines.push(`*Usage:* ${unknown('product usage')}`);
else {
const dir = f.usage_change_pct < 0 ? `down ${-f.usage_change_pct}%` : `up ${f.usage_change_pct}%`;
lines.push(`*Usage:* ${f.avg_daily_active_users} daily active users on average over the last 30 days, ${dir} from the 30 days before. → product data`);
if (f.usage_change_pct <= -20) flags.push('usage');
}
// 4. Contract
if (missing.has('hubspot') || !f.renewal_date) lines.push(`*Contract:* ${unknown('contract dates')}`);
else {
const fmt = d => new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
let c = `renews ${fmt(f.renewal_date)}, in ${days(f.days_to_renewal)}`;
if (f.days_to_notice >= 0 && f.days_to_notice <= 30) {
c += `. Notice deadline (last day to cancel) ${fmt(f.notice_deadline)}, in ${days(f.days_to_notice)}`;
flags.push('notice');
}
lines.push(`*Contract:* ${c}. → HubSpot`);
}
// 5. Last contact
if (missing.has('hubspot')) lines.push(`*Last contact:* ${unknown('CRM')}`);
else {
let l = f.last_meeting_days != null ? `last meeting ${days(f.last_meeting_days)} ago (${f.last_meeting_by})` : 'no meeting logged';
if (f.latest_note_days != null && f.latest_note_days !== f.last_meeting_days)
l += `. ${f.latest_note_by}, ${days(f.latest_note_days)} ago: "${f.latest_note}"`;
lines.push(`*Last contact:* ${end(l)} → HubSpot`);
}
// One suggestion, from the flags only.
let tip;
if (missing.size > 0) tip = `Parts of this customer aren't linked yet (${[...missing].map(m => label[m]).join(', ')}). Ask RevOps to confirm the links, or check those systems yourself.`;
else if (flags.includes('notice')) tip = `They have ${days(f.days_to_notice)} left to give notice. Ask about their renewal plans`
+ (flags.includes('urgent') ? ', and start with the urgent ticket.' : '.');
else if (flags.includes('urgent')) tip = 'Start with the urgent ticket.';
else if (flags.includes('overdue')) tip = 'Mention the overdue invoice, after the agenda.';
else tip = 'Nothing unusual. Use the time for their goals.';
const text = [`*${f.customer}* · ${f.title} · tomorrow ${time}`, ...lines.map(x => '• ' + x), `*Before you call:* ${tip}`].join('\n');
out.push({ json: { meetingId: f.meeting_id, customer: f.customer, text }, pairedItem: { item: i } });
}
return out;A note on contracts
"What's actually in the contract" is the line people most often skip, because the answer sits in a PDF. The fix isn't to have AI read every contract before every call. It's to store the few terms that matter as fields in the CRM when the contract is signed: renewal date, notice period, contract value, and any price or seat agreements. In this example, those are three HubSpot properties. One time, when the deal closes, and every brief after that gets them for free.
Step 4: Send it once, at the right moment
The second workflow runs every hour:
- Find tomorrow's meetings with outside guests. It reads the account owner's calendar for the next 24 hours and drops meetings where everyone is a colleague.
- Match the guests to a customer. The guests' email domains are looked up in the customer domains table. A meeting with someone who isn't a customer, like a sales intro, gets no brief. If the guests belong to more than one customer, the owner gets a one-line note instead of a brief for the wrong account.
- Skip meetings that already have one. Every brief that's sent is logged with the meeting ID. Running the workflow again sends nothing new.
- Collect the facts, write the five lines, post them in Slack. Only briefs that Slack accepted are logged as sent.
In the test, the calendar held five meetings: Northwind, Vanderberg, an internal stand-up, an intro call with a prospect, and an advisory board with guests from two customers. Two briefs went out, plus a note for the advisory board. The stand-up and the intro call were skipped. A second run sent nothing.
What the test shows, and what it doesn't
| Claim | Shown by this test? |
|---|---|
| Both workflows run end to end and produce the briefs above | Yes, in the test setup described in the appendix |
| Meetings are matched correctly, and no brief is sent twice | Yes, for the five meetings tested |
| It works reliably with your real APIs, data volumes and edge cases | No. That needs your accounts, your data and a few weeks of running it |
The test used simplified stand-ins for the APIs, with the same shape of data. It proves the logic, not the integration.
What breaks in production
- Shared or personal email domains. A guest with a Gmail address can't be matched on domain. Match on the contact's email in the CRM as well, or the brief is skipped.
- Meetings with more than one customer. Here they get a note instead of a brief. Some teams prefer one brief per customer; decide which before you start.
- Notes nobody logs. "Has anyone talked to them?" only works if calls and meetings end up in the CRM. A brief that's read every day is the best argument for logging them.
- Stale syncs. If a sync fails silently, the brief describes yesterday. Store when each source last synced, and add a line when it's older than a few hours.
- Usage definitions. "Active user" has to mean the same thing every month, or the trend line is noise.
- Brief fatigue. If every brief has five warnings, nobody reads them. Tune the rules until a flag means "this needs attention in this meeting".
Three ways to implement it
1. A checklist and the same five tabs. Agree on the five lines and the order you check them in. With a handful of meetings a week, a disciplined checklist gets you most of the value.
2. Your CS platform or CRM's own briefing features. Many tools can summarise a record before a meeting. Check which systems they can actually see. If billing and support aren't connected, the summary covers the CRM only.
3. A small customer layer and two workflows, maintained by someone. What this use case shows: links between systems, a synced copy of the data that matters, and a workflow that writes the brief. The most complete option, and the one that needs the most care. It's what we build and maintain at Seamline, but plenty of teams run it in-house.
Want a clearer picture before every customer call?
Your CRM, billing tool and support desk each tell part of the story. The work is connecting the records and agreeing on what the signals mean. Tell us which systems you open before a customer call, and we'll show you how your customers connect across them, and which questions you still can't answer reliably.
Technical appendix: run this yourself
Tested with n8n 1.123 and Postgres 16. The test used simplified stand-ins for the Stripe, Zendesk, HubSpot, Google Calendar and Slack APIs, with responses shaped like the real ones. To run it against your own accounts, replace the HTTP Request nodes with n8n's built-in nodes for those tools, or add your API credentials to them. Contract value, renewal date and notice period are custom HubSpot properties you create yourself.
1. The customer layer (tables):
-- The customer layer. All companies, people and numbers are fictional. -- One ID per customer, and the records that belong to it in each system. CREATE TABLE customers ( customer_id text PRIMARY KEY, name text NOT NULL, owner_name text, owner_slack text -- Slack user ID of the account owner ); CREATE TABLE customer_source_ids ( customer_id text REFERENCES customers, source text, -- hubspot, stripe, zendesk, product source_id text, status text, -- proposed or confirmed; only confirmed counts PRIMARY KEY (source, source_id) ); -- Email domains used to recognise a customer in a calendar invite. CREATE TABLE customer_domains ( domain text PRIMARY KEY, customer_id text REFERENCES customers ); -- Copies of the source data, kept up to date by the sync workflow. CREATE TABLE stripe_subscriptions ( id text PRIMARY KEY, customer text, status text, mrr_eur numeric, synced_at timestamptz DEFAULT now() ); CREATE TABLE stripe_invoices ( id text PRIMARY KEY, customer text, number text, status text, amount_due_eur numeric, due_date date, synced_at timestamptz DEFAULT now() ); CREATE TABLE zendesk_tickets ( id bigint PRIMARY KEY, organization_id text, subject text, status text, priority text, created_at timestamptz, last_agent_reply_at timestamptz, tags text[], synced_at timestamptz DEFAULT now() ); CREATE TABLE hubspot_companies ( id text PRIMARY KEY, name text, contract_mrr_eur numeric, renewal_date date, notice_days int, synced_at timestamptz DEFAULT now() ); CREATE TABLE hubspot_activities ( id text PRIMARY KEY, company_id text, type text, occurred_at timestamptz, logged_by text, summary text, synced_at timestamptz DEFAULT now() ); -- Product usage usually already lives in your own database. CREATE TABLE product_usage_daily ( account_id text, day date, active_users int, PRIMARY KEY (account_id, day) ); -- Which briefs were sent, so nobody gets the same one twice. CREATE TABLE brief_log ( meeting_id text PRIMARY KEY, customer_id text, sent_at timestamptz DEFAULT now(), brief text );
2. Sample links, domains and product usage:
INSERT INTO customers VALUES
('CUST_001', 'Northwind Analytics', 'Sanne de Vries', 'U01SANNE'),
('CUST_002', 'Vanderberg Mobility', 'Sanne de Vries', 'U01SANNE');
INSERT INTO customer_source_ids VALUES
('CUST_001', 'hubspot', 'hs_101', 'confirmed'),
('CUST_001', 'stripe', 'cus_N1', 'confirmed'),
('CUST_001', 'zendesk', 'org_501', 'confirmed'),
('CUST_001', 'product', 'acc_7', 'confirmed'),
('CUST_002', 'hubspot', 'hs_204', 'confirmed'),
('CUST_002', 'stripe', 'cus_V9', 'proposed'); -- suggested by a rule, nobody confirmed it yet
INSERT INTO customer_domains VALUES
('northwind.io', 'CUST_001'),
('vanderberg-mobility.nl', 'CUST_002');
-- Product usage: Northwind's active users fall from about 40 to about 26.
INSERT INTO product_usage_daily
SELECT 'acc_7', d::date,
CASE WHEN d < date '2026-09-15' THEN 40 + (extract(day FROM d)::int % 3)
ELSE 26 + (extract(day FROM d)::int % 2) END
FROM generate_series(date '2026-08-16', date '2026-10-14', interval '1 day') d;3. The brief function from step 3.
4. The two n8n workflows used in this test. They're examples built for the sample setup, not ready for production: they point at the test stand-ins, use a manual trigger, and contain no credentials. A note inside each workflow lists what to change. Import them in n8n via Workflows → Import from file:
Want this done for your data? See how we work → /services/crm-data-cleansing
Want this fixed on your data? Book a diagnostic.
Book a diagnosticRelated question: “What do I need to know before this call?”