DEV Community

Cover image for Build a CRM Backend on Notion's API in 2026: $5/Month Stack with Node.js
TrackStack
TrackStack

Posted on • Originally published at trackstack.tech

Build a CRM Backend on Notion's API in 2026: $5/Month Stack with Node.js

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]
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 },
  }),
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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}`,
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

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)