The full version of this article teaches non-developers how to configure Notion's UI into a working CRM. This is the developer take: don't configure the UI — automate around the API. I run a small-business CRM stack on Notion for ~$5/month total infrastructure (Notion Free + a $6 VPS for cron jobs and webhooks). Below is the actual code, the rate-limit math, and where this breaks down.
If you're a non-dev founder, read the full version for the UI-first setup. If you're a developer who wants to skip the SaaS subscription and own your stack, this is for you.
The architecture
[Website form]
↓ webhook
[Node.js receiver on VPS]
↓ Notion API
[Notion databases: Contacts, Deals, Activities]
↓ Notion webhooks
[Slack notifier on stage change]
[Cron @ 9am daily]
↓ Notion API
[Stale lead detector → Slack DM]
Four small services, all talking to Notion as the data layer. No CRM subscription. No Zapier monthly fee. ~80 lines of Node.js total. The actual CRM "logic" lives in your code, not in someone else's billing engine.
Step 1: Website form → Notion contact
Create a Notion integration at notion.so/profile/integrations, copy the secret, and share your Contacts database with it. Database ID comes from the URL (the 32-char hex after the workspace name).
import { Client } from '@notionhq/client';
import express from 'express';
import crypto from 'crypto';
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const CONTACTS_DB = process.env.NOTION_CONTACTS_DB_ID;
const app = express();
app.use(express.json());
app.post('/lead', async (req, res) => {
// Verify the source — your form should sign the payload
const sig = req.headers['x-form-signature'];
const computed = crypto
.createHmac('sha256', process.env.FORM_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (sig !== computed) return res.status(401).end();
const { email, name, company, message, utm_source } = req.body;
if (!email) return res.status(400).json({ error: 'email required' });
// Dedupe by email — query existing first
const existing = await notion.databases.query({
database_id: CONTACTS_DB,
filter: { property: 'Email', email: { equals: email } },
page_size: 1,
});
if (existing.results.length) {
// Update last-contacted on the existing contact
await notion.pages.update({
page_id: existing.results[0].id,
properties: {
'Last Contacted': { date: { start: new Date().toISOString() } },
},
});
return res.json({ status: 'updated', id: existing.results[0].id });
}
// Create new contact
const page = await notion.pages.create({
parent: { database_id: CONTACTS_DB },
properties: {
Name: { title: [{ text: { content: name || email } }] },
Email: { email },
Company: { rich_text: [{ text: { content: company || '' } }] },
Status: { select: { name: 'new lead' } },
Source: { select: { name: utm_source || 'website' } },
'Last Contacted': { date: { start: new Date().toISOString() } },
Notes: { rich_text: [{ text: { content: message || '' } }] },
},
});
res.json({ status: 'created', id: page.id });
});
app.listen(3000);
Drop this on a Hetzner/DO/Linode $5-6/month VPS, point your form's webhook at https://crm.yourdomain.com/lead, slap Caddy in front for TLS. Your website form now creates Notion contacts with deduplication.
The dedupe-by-email pattern is the single most useful thing here — without it, retries and double-submissions clutter your database with duplicates within a week. Always upsert.
Step 2: Notion webhook → Slack on stage change
Notion shipped first-party webhooks in late 2024 — finally usable for reactive automations. Subscribe via the API to events on your Deals database:
// One-time setup: register a webhook for the Deals database
await fetch('https://api.notion.com/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28',
},
body: JSON.stringify({
url: 'https://crm.yourdomain.com/notion-webhook',
event_types: ['page.properties_updated'],
filter: { database_id: process.env.NOTION_DEALS_DB_ID },
}),
});
Then handle the events:
app.post('/notion-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify Notion's signature
const signature = req.headers['x-notion-signature'];
const computed = crypto
.createHmac('sha256', process.env.NOTION_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (signature !== `sha256=${computed}`) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'page.properties_updated') {
const pageId = event.entity.id;
const page = await notion.pages.retrieve({ page_id: pageId });
const stage = page.properties.Stage?.select?.name;
const dealName = page.properties['Deal Name']?.title?.[0]?.plain_text;
const value = page.properties.Value?.number;
if (stage === 'won') {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({
text: `🎉 Deal won: *${dealName}* — $${value?.toLocaleString() || '?'}`,
}),
});
}
}
res.status(200).end();
});
Now when anyone on your team drags a deal card to "won" in Notion, your sales channel gets a celebration message. Same pattern for "lost" with a different emoji, or "negotiation" to ping the deal owner.
The catch: Notion's webhooks fire on every property change, including bulk imports. If you update 50 deals in a script, you'll get 50 webhook calls. Add idempotency (track recently-seen event IDs in Redis or a sqlite file) and rate-limit your Slack notifications client-side.
Step 3: Stale lead detector (the cron that actually saves leads)
The single most valuable automation: a daily check for leads who haven't been contacted in 30+ days. This is what a real CRM does automatically; we'll build it in 30 lines:
// stale-leads.js — run via cron @ 9am daily
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const THIRTY_DAYS_AGO = new Date(Date.now() - 30 * 86_400_000).toISOString();
async function findStaleLeads() {
const stale = [];
let cursor = undefined;
do {
const res = await notion.databases.query({
database_id: process.env.NOTION_CONTACTS_DB_ID,
filter: {
and: [
{ property: 'Status', select: { equals: 'new lead' } },
{ property: 'Last Contacted', date: { before: THIRTY_DAYS_AGO } },
],
},
start_cursor: cursor,
page_size: 100,
});
stale.push(...res.results);
cursor = res.has_more ? res.next_cursor : undefined;
} while (cursor);
return stale;
}
const stale = await findStaleLeads();
if (stale.length) {
const lines = stale
.slice(0, 10)
.map((p) => {
const name = p.properties.Name?.title?.[0]?.plain_text || 'Unnamed';
const email = p.properties.Email?.email || '';
const lastContacted = p.properties['Last Contacted']?.date?.start;
return `• *${name}* (${email}) — last touched ${lastContacted}`;
})
.join('\n');
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({
text: `☕ Morning. ${stale.length} stale leads (${stale.length > 10 ? 'top 10' : 'all'}):\n${lines}`,
}),
});
}
crontab -e and add 0 9 * * * /usr/bin/node /home/crm/stale-leads.js. Every morning at 9, your Slack gets a digest of leads who need a nudge.
This is genuinely transformative for a 1-3 person sales motion. The cost of forgetting a warm lead for 60 days is way bigger than the cost of running this cron.
Rate limits and pagination math
Notion's API is rate-limited to ~3 requests per second average per integration. Burst above that and you'll get rate_limited errors. Database queries return max 100 results per page; full-database scans need pagination.
For a CRM with ~500 contacts and ~100 active deals, every operation is well within limits. The numbers that matter:
- Form submission: 2 API calls (query for dedupe + create/update). At 100 leads/day = 200 calls = 6,000/month.
- Webhook handler: 1 API call per event (retrieve page). At 50 stage changes/day = 1,500 calls/month.
- Daily stale-lead cron: ~5-10 paginated queries. ~300 calls/month.
Total: ~8,000 API calls/month. The Notion API has no hard monthly cap on free tier — just the per-second rate limit. You're effectively unlimited at this scale.
Where you'd hit limits: bulk imports (CSV migration of 10,000 contacts), aggressive polling instead of webhooks, or running this for 10+ teams off one integration. Use multiple integration tokens if you scale that far.
Where this stack breaks down
Honest list — when it's time to graduate to a real CRM:
- Email integration. Notion's API doesn't have native email send/track. You can wire Postmark or SES for sending, but you're building a mini email-CRM yourself. Past ~10 sequences and >1k subscribers, use a real ESP.
- Multi-step sequences. A "3 emails over 7 days" drip campaign is doable but you're maintaining state in Notion which is awkward. Tools like Customer.io exist for this.
- Team scaling. Notion permissions are page-level. "Sales rep X sees only their leads" requires hacks (separate workspaces, complex view filters). Real CRMs do this in 30 seconds.
- Reporting trust. Your CFO wants a forecast. You can build pipeline-value sums in Notion, but win-rate trends, MEDDIC scoring, rep performance — those are weeks of formula-and-rollup hell. Buy a CRM.
For graduation paths, our Pipedrive vs HubSpot for B2B comparison and best CRM for small business roundup are the natural reads.
- The full stack: Notion (Free) + $5-6/month VPS + Caddy + ~80 lines of Node.js. Total infra: ~$5-10/month.
- What you get: dedup-on-submit, webhook-driven Slack notifications, stale-lead detection. The 90% of a real CRM that actually matters.
- What you don't get: native email, sequences, sales-team reporting, deliverability tracking. Build these and you're rewriting HubSpot.
- When it breaks: past 500 contacts + email-heavy workflows + 5+ team members. Graduate at that point — don't fight it.
The pattern generalises beyond CRM. Notion API + small Node.js services replaces a lot of $30-100/month SaaS subscriptions for early-stage businesses. The trade is: you own the stack, you maintain it, and one bug at 2 AM is yours to fix. For dev-founders who like that trade, it's a great deal.
Originally published on trackstack.tech with the UI-first setup walkthrough and FAQ.
Top comments (0)