<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: TrackStack</title>
    <description>The latest articles on DEV Community by TrackStack (@trackstack).</description>
    <link>https://dev.to/trackstack</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3869261%2Fbb568a90-1a2c-4171-a96d-cde864e05263.png</url>
      <title>DEV Community: TrackStack</title>
      <link>https://dev.to/trackstack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trackstack"/>
    <language>en</language>
    <item>
      <title>Hotjar vs Microsoft Clarity vs LogRocket</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Wed, 01 Jul 2026 18:18:08 +0000</pubDate>
      <link>https://dev.to/trackstack/hotjar-vs-microsoft-clarity-vs-logrocket-5888</link>
      <guid>https://dev.to/trackstack/hotjar-vs-microsoft-clarity-vs-logrocket-5888</guid>
      <description>&lt;p&gt;Heatmaps and session recordings all look similar in a demo, which makes &lt;strong&gt;Hotjar vs Microsoft Clarity vs LogRocket&lt;/strong&gt; a deceptively hard choice. The truth is these three are not really competitors — they are built for three different jobs. One is free and aimed at marketers, one is a qualitative research suite, and one is a developer debugging tool that happens to record sessions. Picking by price alone is how teams end up with the wrong fit.&lt;/p&gt;

&lt;p&gt;This comparison is for SMB marketers, product owners, and developers deciding where to invest in behavior analytics in 2026 — what each tool is actually for, real pricing (including a major change to Hotjar), retention and privacy trade-offs, and a clear framework so you choose by use case rather than by a feature list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer: which one to use
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Microsoft Clarity&lt;/strong&gt; is the default for most teams: it is genuinely free with unlimited sessions, strong heatmaps, and AI summaries — start here unless you have a specific reason not to. &lt;strong&gt;Hotjar&lt;/strong&gt; earns its price when you need the qualitative layer Clarity lacks: surveys, feedback widgets, funnels, and 365-day retention. &lt;strong&gt;LogRocket&lt;/strong&gt; is for engineering teams that need to debug — it captures console logs, network requests, and JavaScript errors alongside the replay. Pick Clarity for free behavior data, Hotjar to understand the "why," and LogRocket to fix the "what broke."&lt;/p&gt;

&lt;h2&gt;
  
  
  What each tool is built for
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Microsoft Clarity — free behavior analytics
&lt;/h3&gt;

&lt;p&gt;Clarity is a 100% free session-recording and heatmap tool with no paid tier and no session caps. Beyond standard click and scroll maps, it includes two diagnostics competitors charge for: &lt;strong&gt;dead-click heatmaps&lt;/strong&gt; (clicks on things that are not interactive) and &lt;strong&gt;error-click heatmaps&lt;/strong&gt; (clicks that trigger JavaScript errors), which are gold for spotting broken UI. In 2026 it also bakes in Copilot AI to summarise sessions, plus a native GA4 integration. It pairs naturally with your &lt;a href="https://trackstack.tech/en/ga4-website-basic-setup-10-common-mistakes/" rel="noopener noreferrer"&gt;basic GA4 setup&lt;/a&gt; as the qualitative half of the picture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hotjar — the qualitative UX suite
&lt;/h3&gt;

&lt;p&gt;Hotjar pairs heatmaps and recordings with the tools that explain behaviour: on-page surveys, feedback widgets, user interviews, and conversion funnels. It also adds move maps (tracking mouse hover, which correlates with attention) and automatic frustration and engagement scoring so you can skip to the recordings that matter. One important 2026 change: Hotjar officially merged into Contentsquare on &lt;strong&gt;July 1, 2025&lt;/strong&gt;, so its pricing page now redirects there and the product is being folded into Contentsquare's modules.&lt;/p&gt;

&lt;h3&gt;
  
  
  LogRocket — developer debugging
&lt;/h3&gt;

&lt;p&gt;LogRocket records sessions, but its real job is engineering. It captures console logs, network requests, JavaScript errors, and application state, so a developer can replay exactly what happened before a bug and connect a UX issue straight to a stack trace. It is the tool of choice when your engineers own support escalations and debugging time is the bottleneck — and it tends to overlap with performance work like &lt;a href="https://trackstack.tech/en/core-web-vitals-2026-what-really-matters-and-how-to-fix-it-fast/" rel="noopener noreferrer"&gt;Core Web Vitals&lt;/a&gt; rather than marketing CRO.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026 pricing
&lt;/h2&gt;

&lt;p&gt;The pricing gap here is the widest in the category — from completely free to enterprise quotes. Because of the Contentsquare transition and LogRocket's tiering, verify current numbers before committing; the figures below reflect mid-2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microsoft Clarity&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; $0 — unlimited sessions, recordings, and heatmaps, no caps, no credit card&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid tiers:&lt;/strong&gt; none — there is no "Clarity Pro"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That genuinely-free model is unusual in a category where "free" normally means a few hundred sessions before a paywall. Clarity's only real ceiling is retention and feature depth, not volume — you will never get an overage bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hotjar (now Contentsquare)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; $0 — the new Contentsquare free plan offers around 5,000 monthly sessions with no sampling (a big upgrade from the old 35-sessions-per-day cap)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid:&lt;/strong&gt; from roughly $32–$49/month depending on plan and session volume, with 365-day data retention on all plans&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher tiers:&lt;/strong&gt; scale to unlimited sessions with sampling, into the ~$171/month range&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;LogRocket&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; $0 — around 1,000 sessions per month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team / Developer:&lt;/strong&gt; roughly $69–$99/month for about 10,000 sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher tiers:&lt;/strong&gt; around $349/month for 50,000 sessions; AI error context (Galileo) sits on the upper plans&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One pricing nuance that bites: LogRocket and most paid tools are "always-on," so every visit counts against quota. On a high-traffic site a 10,000-session plan can be exhausted in days, while Clarity simply never charges. Count cost per thousand sessions against your real traffic, not the monthly headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Clarity&lt;/th&gt;
&lt;th&gt;Hotjar&lt;/th&gt;
&lt;th&gt;LogRocket&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free + from ~$32/mo&lt;/td&gt;
&lt;td&gt;Free + from ~$69/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Session limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;~5,000/mo free&lt;/td&gt;
&lt;td&gt;~1,000/mo free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data retention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~30 days&lt;/td&gt;
&lt;td&gt;365 days&lt;/td&gt;
&lt;td&gt;Plan-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Surveys / feedback&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dev tooling (errors, network)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic JS errors&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Full (console, network, state)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Copilot summaries&lt;/td&gt;
&lt;td&gt;In Contentsquare&lt;/td&gt;
&lt;td&gt;Galileo (higher tiers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free behaviour data&lt;/td&gt;
&lt;td&gt;UX research, the "why"&lt;/td&gt;
&lt;td&gt;Engineering debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Real-world scenarios
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fixing a leaking checkout.&lt;/strong&gt; If customers abandon at checkout, Clarity's free recordings and dead-click maps will show you where they get stuck — for nothing. Watch ten sessions of the checkout flow and the friction is usually obvious. Dead-click maps in particular reveal when users tap something that looks clickable but is not — a common hidden cause of checkout drop-off that standard analytics never surfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Understanding why a landing page underperforms.&lt;/strong&gt; When a page gets traffic but no conversions, heatmaps tell you what people do, not why. Hotjar's on-page survey ("What stopped you signing up today?") plus its move maps close that gap. This is where paying for Hotjar earns its keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproducing a customer-reported bug.&lt;/strong&gt; When support says "a user couldn't submit the form and saw an error," marketing tools cannot help. LogRocket lets a developer replay that exact session with the console errors and failed network calls visible inline, turning a vague report into a reproducible bug. The rule of thumb: if the person who will watch the replay is a developer, LogRocket pays off; if it is a marketer or designer, Clarity or Hotjar will tell them what they need without the price tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where Clarity falls short.&lt;/strong&gt; Recordings are typically retained only about 30 days, so long-term trend analysis is out. It has no surveys, feedback widgets, or funnels, and you cannot look up a session by a customer's email or name — only non-identifying custom tags. The biggest catch behind "free": Microsoft reserves the right to use your aggregated, anonymised data to improve its own products and AI, which can be a non-starter for healthcare, finance, or government teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where Hotjar falls short.&lt;/strong&gt; Cost climbs as traffic grows, and the Contentsquare merger has made the product and pricing feel less simple than the old "install a script and see heatmaps by the afternoon" Hotjar. It is web-only with no native mobile SDK, and it offers no developer tooling — no console, network, or error capture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where LogRocket falls short.&lt;/strong&gt; It is the most expensive and the most complex, and marketers will find it overkill. Its always-on model burns through session quotas fast on high-traffic sites, and the best AI error context sits behind upper tiers. If nobody on your team is going to use the console logs and network traces, you are paying for power you will never touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose Microsoft Clarity&lt;/strong&gt; if you want free, unlimited behaviour data and can accept 30-day retention and the data-ownership trade-off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose Hotjar&lt;/strong&gt; if you need surveys, feedback, funnels, and long retention to understand the "why" behind behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose LogRocket&lt;/strong&gt; if your engineers own debugging and you need console, network, and error data with each replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run Clarity plus Hotjar&lt;/strong&gt; if budget allows — many teams use Clarity for unlimited volume and Hotjar for qualitative depth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid LogRocket&lt;/strong&gt; if you are a marketing-only team; the depth is wasted and the cost is high.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid Clarity&lt;/strong&gt; if your compliance rules forbid sharing behavioural data with a third party.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Hotjar, Microsoft Clarity, and LogRocket are not three versions of the same product — they are a marketing tool, a research suite, and an engineering tool. For most SMBs the smartest starting move is free: install Clarity, watch real sessions, and only add Hotjar when you need to ask users why, or LogRocket when your developers need to debug what broke. Match the tool to the job and you avoid paying for power you will not use. When you are ready to act on what you find, pair these insights with proper &lt;a href="https://trackstack.tech/en/ga4-ecommerce-events-setup-guide/" rel="noopener noreferrer"&gt;GA4 ecommerce events&lt;/a&gt; so the qualitative story and the quantitative numbers finally line up.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Microsoft Clarity really free?&lt;/strong&gt;&lt;br&gt;
Yes, completely — no paid tier, no session caps, no credit card. The trade-off is data ownership: Microsoft uses aggregated, anonymised Clarity data to improve its own products and AI, and recordings are retained only about 30 days. For most businesses that is acceptable; for strict-governance industries it may not be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Hotjar still available after the Contentsquare merger?&lt;/strong&gt;&lt;br&gt;
Yes. Hotjar merged into Contentsquare on July 1, 2025, and its pricing page now redirects there, but the product still works. The free plan actually improved to around 5,000 monthly sessions, though the tooling is gradually being folded into Contentsquare's broader modules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Clarity and Hotjar together?&lt;/strong&gt;&lt;br&gt;
Yes, and many optimisation teams do. There is no technical conflict running both. A common setup is Clarity on every page for unlimited volume and frustration signals, with Hotjar for surveys, feedback, and deeper qualitative research where it adds the most value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which tool is best for developers?&lt;/strong&gt;&lt;br&gt;
LogRocket, clearly. It captures console logs, network requests, JavaScript errors, and application state alongside the session replay, so developers can reproduce bugs from a customer report. Clarity surfaces basic JS errors and error-clicks, but it lacks the deep debugging tooling LogRocket provides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Clarity or Hotjar slow down my site?&lt;/strong&gt;&lt;br&gt;
Both add a tracking script, so there is a small performance cost, as with any analytics tool. The impact is usually minor, but always-on session recording on heavy pages can add weight — worth checking against your performance budget if page speed is a priority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the catch with Microsoft Clarity being free?&lt;/strong&gt;&lt;br&gt;
The catch is data and retention, not money. Microsoft funds Clarity as a loss-leader and uses anonymised behavioural data to improve Bing, Edge, and its AI systems, and recordings expire after roughly 30 days. If those terms fit your privacy posture, it is the best free behaviour tool available.&lt;/p&gt;

&lt;p&gt;These three are not really competitors. &lt;strong&gt;Clarity&lt;/strong&gt; is genuinely free (unlimited sessions, heatmaps, dead-click/error-click maps, Copilot summaries) — the default starting point, with ~30-day retention and a data-ownership trade-off. &lt;strong&gt;Hotjar&lt;/strong&gt; (now part of Contentsquare since July 2025) adds the qualitative layer — surveys, feedback, funnels, 365-day retention — from ~$32/mo. &lt;strong&gt;LogRocket&lt;/strong&gt; is a developer tool: console, network, errors, and state alongside the replay, from ~$69/mo and always-on. Start with Clarity, add Hotjar to learn &lt;em&gt;why&lt;/em&gt;, add LogRocket to debug &lt;em&gt;what broke&lt;/em&gt;.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>ux</category>
      <category>webdev</category>
      <category>tools</category>
    </item>
    <item>
      <title>How to Track Form Submissions in GA4 Without Coding (2026)</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Mon, 29 Jun 2026 17:32:36 +0000</pubDate>
      <link>https://dev.to/trackstack/how-to-track-form-submissions-in-ga4-without-coding-2026-2ln1</link>
      <guid>https://dev.to/trackstack/how-to-track-form-submissions-in-ga4-without-coding-2026-2ln1</guid>
      <description>&lt;p&gt;Your contact form is probably the most valuable interaction on your site — and the one most likely to be tracked badly or not at all. The good news: you can &lt;strong&gt;track form submissions in GA4&lt;/strong&gt; without writing a single line of code. The catch is that the easiest method is also the least reliable, so picking the right approach for your specific form matters more than the setup itself.&lt;/p&gt;

&lt;p&gt;This guide is for marketers and developers who already have &lt;a href="https://trackstack.tech/en/ga4-website-basic-setup-10-common-mistakes/" rel="noopener noreferrer"&gt;basic GA4 setup&lt;/a&gt; running and want accurate lead data without shipping custom code — four no-code methods, when each works and when it quietly fails, how to turn a tracked submission into a key event, and the pitfalls (double counting, inflated numbers, embedded forms) that wreck most setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer: which no-code method to use
&lt;/h2&gt;

&lt;p&gt;If your form redirects to a thank-you page, track visits to that page — it is the most reliable no-code method. If it does not redirect, use Google Tag Manager: the built-in &lt;strong&gt;Form Submission trigger&lt;/strong&gt; for standard HTML forms, or an &lt;strong&gt;Element Visibility trigger&lt;/strong&gt; on the success message for AJAX forms. GA4's built-in &lt;strong&gt;Enhanced Measurement&lt;/strong&gt; can track forms with zero setup, but it is notoriously unreliable and often inflates numbers, so treat it as a quick test rather than a final solution. Whatever you pick, verify it in DebugView and mark it as a key event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 no-code methods
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Method 1 — Enhanced Measurement (zero setup, big catch)
&lt;/h3&gt;

&lt;p&gt;GA4 can track forms automatically with no tools at all. Go to &lt;strong&gt;Admin → Data Streams → your web stream → Enhanced Measurement&lt;/strong&gt;, open the settings, and toggle on "Form interactions." GA4 then attempts to fire &lt;code&gt;form_start&lt;/code&gt; and &lt;code&gt;form_submit&lt;/code&gt; events on its own.&lt;/p&gt;

&lt;p&gt;The honest reality: this is widely regarded as one of GA4's least reliable features. It only catches standard HTML &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; elements with native submit behaviour, so it misses most modern AJAX and JavaScript forms — and when it does fire, it frequently over-counts, with some sites reporting thousands of phantom submissions a day. It also fires &lt;code&gt;form_submit&lt;/code&gt; on interactions that are not real submissions, so a single visitor can generate several. Use it for a five-minute sanity check, not as your reporting source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 2 — thank-you page (the most reliable)
&lt;/h3&gt;

&lt;p&gt;If submitting your form sends users to a dedicated confirmation page, that redirect is the cleanest signal you have. In GTM, create a trigger of type &lt;strong&gt;Page View&lt;/strong&gt; with the condition &lt;code&gt;Page Path equals /thank-you/&lt;/code&gt;, then attach a GA4 Event tag named something like &lt;code&gt;generate_lead&lt;/code&gt;. When the thank-you page loads, the event fires.&lt;/p&gt;

&lt;p&gt;This method needs no understanding of how the form submits, works across every form type, and is the most trustworthy of the four. Its one weakness: anyone reaching that URL directly — via a bookmark, email link, or internal link — counts as a conversion. Make the thank-you page unreachable except by submitting, and the data stays clean. It is the ideal pairing for a &lt;a href="https://trackstack.tech/en/lead-magnet-automated-funnel-structure/" rel="noopener noreferrer"&gt;lead magnet funnel&lt;/a&gt; where the confirmation page already exists. To verify, open GTM Preview, submit the form, confirm the tag fires on the thank-you URL, then check GA4 Realtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 3 — GTM Form Submission trigger
&lt;/h3&gt;

&lt;p&gt;For standard forms that do not redirect, GTM has a built-in listener. First enable the &lt;strong&gt;Form built-in variables&lt;/strong&gt; (Variables → Configure → tick the Form group). Then create a trigger of type &lt;strong&gt;Form Submission&lt;/strong&gt;, leave "Check Validation" on so it only fires on successful submits, and target a specific form by Form ID if you have several. Attach your GA4 Event tag and you are done — no code.&lt;/p&gt;

&lt;p&gt;The limitation is real: this listener was designed for old-school forms that POST and reload, so it does not reliably detect AJAX submissions where the page never navigates. If Preview mode shows no &lt;code&gt;gtm.formSubmit&lt;/code&gt; event when you submit, your form is almost certainly AJAX — move to Method 4. When it does work, it is genuinely set-and-forget and captures useful context like the form ID and page automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 4 — Element Visibility for AJAX forms
&lt;/h3&gt;

&lt;p&gt;Most modern forms submit in the background and show an inline "Thanks, we got your message" without reloading. The no-code trick is to track when that success message appears. In GTM, create an &lt;strong&gt;Element Visibility trigger&lt;/strong&gt;, point it at the confirmation element with a CSS selector (for example &lt;code&gt;.form-success&lt;/code&gt;), enable "Observe DOM changes," and fire your GA4 Event tag on it. The message only appears on a genuine submission, so it is a clean signal — just pick a stable selector and test it in Preview first.&lt;/p&gt;

&lt;p&gt;Many form plugins make this even easier by pushing their own dataLayer event on submit, which a GTM Custom Event trigger can catch directly. If your plugin offers a native GA4 or dataLayer integration, that is usually the most robust no-code route of all — let the plugin do the listening for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four methods compared
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Needs GTM?&lt;/th&gt;
&lt;th&gt;Works on AJAX forms?&lt;/th&gt;
&lt;th&gt;Reliability&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enhanced Measurement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Low (often inflated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Thank-you page&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (if it redirects)&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Form Submission trigger&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Good (standard forms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Element Visibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Good (needs a CSS selector)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Turn the event into a key event
&lt;/h2&gt;

&lt;p&gt;Tracking the event is only half the job — to use it as a conversion, you must promote it. In GA4 go to &lt;strong&gt;Admin → Events&lt;/strong&gt;, find your form event (e.g. &lt;code&gt;generate_lead&lt;/code&gt;), and toggle "Mark as key event." Note the terminology: Google renamed "conversions" to "key events" in March 2024, though they still appear as conversions when imported into Google Ads — the function is identical, only the label changed. (If a colleague asks where conversions went, that is the answer.)&lt;/p&gt;

&lt;p&gt;For sharper reporting, add a &lt;code&gt;value&lt;/code&gt; parameter to the tag so a quote request can be worth more than a newsletter signup — say 100 versus 10 — which feeds straight into ROI calculations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls that wreck your data
&lt;/h2&gt;

&lt;p&gt;Form tracking fails quietly more often than loudly. These are the issues that make your numbers wrong while everything looks fine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Double counting:&lt;/strong&gt; running Enhanced Measurement form tracking and a custom GTM setup at once fires two events per submission. If you build custom tracking, turn off the Form interactions toggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inflated thank-you pages:&lt;/strong&gt; direct visits, bookmarks, and email links to the confirmation URL all count as conversions. Block direct access to that page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded third-party forms:&lt;/strong&gt; HubSpot and Typeform sit in cross-origin iframes that standard triggers cannot read. HubSpot fires a global JavaScript event GTM can catch; Typeform can redirect to a thank-you page on your domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bots and spam:&lt;/strong&gt; automated submissions inflate counts — filter aggressively and sanity-check against reality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent gaps:&lt;/strong&gt; if your cookie banner blocks GA4 before consent, submissions go untracked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The single best habit is to cross-check monthly. Compare GA4's form event count against the records in your inbox or CRM — if GA4 shows double, you have a duplication problem; if it shows far fewer, tracking is missing submissions. Tracking that worked last month can break silently after a theme, plugin, or layout change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which method should you use?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thank-you page&lt;/strong&gt; if your form redirects — the most reliable and least fragile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM Form Submission trigger&lt;/strong&gt; for standard HTML forms that POST and reload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Element Visibility&lt;/strong&gt; for AJAX forms with an inline success message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your form plugin's native integration&lt;/strong&gt; if it offers one — often the most robust no-code option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enhanced Measurement only&lt;/strong&gt; as a quick test, never as your final reported source of truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most teams the practical answer is a thank-you page where possible and Element Visibility everywhere else. You do not need one method for the whole site — a contact form can use the thank-you page while a pop-up lead magnet uses Element Visibility, as long as event names stay consistent. Once leads are tracked accurately, the natural next step is routing them onward — for example to &lt;a href="https://trackstack.tech/en/website-crm-telegram-integration/" rel="noopener noreferrer"&gt;send leads to your CRM&lt;/a&gt; the moment a form is submitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You do not need a developer to track form submissions in GA4 — you need the right method for how your form behaves. Thank-you page tracking is the most reliable, Element Visibility covers AJAX forms, the GTM Form Submission trigger handles standard ones, and Enhanced Measurement is a quick test at best. Whichever you choose, verify it in DebugView, mark it as a key event, watch for double counting, and cross-check against your CRM every month.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I track form submissions in GA4 without Google Tag Manager?&lt;/strong&gt;&lt;br&gt;
Yes, using Enhanced Measurement's Form interactions toggle, which needs no GTM. The trade-off is reliability — it only catches standard HTML forms and often over-counts. For accurate data without GTM, the alternative is a form plugin with a native GA4 integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is GA4 not tracking my form submissions?&lt;/strong&gt;&lt;br&gt;
The most common reason is an AJAX form that does not trigger a standard browser submit, so Enhanced Measurement and the GTM Form Submission trigger both miss it. Use an Element Visibility trigger on the success message instead, and confirm in GTM Preview that an event actually fires on submit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does GA4 show more form submissions than I received?&lt;/strong&gt;&lt;br&gt;
Usually double counting — Enhanced Measurement form tracking running alongside a custom GTM setup, both firing on one submission. Disable the Form interactions toggle if you built your own tracking. Inflated thank-you pages from direct visits and bot spam are the other common causes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I mark a form submission as a conversion?&lt;/strong&gt;&lt;br&gt;
In GA4, go to Admin → Events, find your form event, and toggle "Mark as key event." Google renamed conversions to key events in 2024, but they still import into Google Ads as conversions. The event must have fired at least once before it appears in the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I track HubSpot or Typeform forms in GA4?&lt;/strong&gt;&lt;br&gt;
Yes, but not with standard triggers, because they load in cross-origin iframes. HubSpot fires a global JavaScript event that GTM can listen for with a Custom Event trigger. Typeform can redirect to a thank-you page on your own domain, which GA4 then tracks as a page view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Enhanced Measurement form tracking accurate?&lt;/strong&gt;&lt;br&gt;
Not reliably. It works for basic HTML forms on simple sites but misses AJAX and JavaScript forms and frequently inflates submission counts. For trustworthy data, use a thank-you page or a GTM trigger instead, and disable Enhanced Measurement form tracking to avoid duplicates.&lt;/p&gt;

&lt;p&gt;You can track GA4 form submissions with no code. Four methods: &lt;strong&gt;thank-you page&lt;/strong&gt; (most reliable — fire a GA4 event on the confirmation pageview), &lt;strong&gt;GTM Form Submission trigger&lt;/strong&gt; (standard HTML forms only), &lt;strong&gt;Element Visibility&lt;/strong&gt; (AJAX forms — fire when the success message appears), and &lt;strong&gt;Enhanced Measurement&lt;/strong&gt; (zero setup but unreliable, a quick test at best). Verify in DebugView, mark the event as a key event, don't run Enhanced Measurement alongside custom tracking (double counting), and cross-check GA4 against your CRM monthly.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>ga4</category>
      <category>marketing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GA4 Custom Dimensions: A Setup Guide with Real Examples (2026)</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Sat, 27 Jun 2026 15:51:32 +0000</pubDate>
      <link>https://dev.to/trackstack/ga4-custom-dimensions-a-setup-guide-with-real-examples-2026-pei</link>
      <guid>https://dev.to/trackstack/ga4-custom-dimensions-a-setup-guide-with-real-examples-2026-pei</guid>
      <description>&lt;p&gt;GA4 ships with dozens of built-in dimensions, but the moment you want to report on something specific to your business — which blog category drives signups, which membership tier converts, which product colour sells — you hit the wall. That is where &lt;strong&gt;GA4 custom dimensions&lt;/strong&gt; come in: they let you attach your own data to events and users and make it visible in reports.&lt;/p&gt;

&lt;p&gt;This guide is for marketers and developers who already have &lt;a href="https://trackstack.tech/en/ga4-website-basic-setup-10-common-mistakes/" rel="noopener noreferrer"&gt;basic GA4 setup&lt;/a&gt; running and want to extend it without breaking anything — what custom dimensions really are, the three scopes, the exact 2026 limits, a step-by-step setup with GTM, real examples for each scope, and the mistakes that quietly ruin your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer: how custom dimensions work
&lt;/h2&gt;

&lt;p&gt;A custom dimension is two things working together: an event parameter (or user property) you &lt;em&gt;send&lt;/em&gt; from your site, and a registration in GA4 Admin that &lt;em&gt;maps&lt;/em&gt; that parameter to a readable name and a scope. Both halves are required — sending without registering means the data sits invisible in raw collection; registering without sending means an empty dimension. Crucially, registration is not retroactive: data only appears from the moment you register it forward, so set it up before you need the history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GA4 custom dimensions actually are
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Event parameter vs registered dimension.&lt;/strong&gt; An event parameter is the key-value pair sent with an event at collection time — for example, &lt;code&gt;file_name: "pricing.pdf"&lt;/code&gt; sent with a &lt;code&gt;file_download&lt;/code&gt; event. The parameter is where the data lives. A custom dimension is the registration in GA4 Admin that connects that parameter name to a display label and makes it visible in standard reports, Explorations, and audiences. Without registration, the parameter still exists in raw collection and in the BigQuery export, but it is invisible in the GA4 interface. This split trips people up constantly: they see the parameter in DebugView, assume the job is done, then wonder why it never shows in a report. The two steps are separate by design, and you need both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The three scopes: event, user, item.&lt;/strong&gt; Scope decides how the value attaches to your data, and choosing wrong is the single most common cause of broken reports.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Event-scoped&lt;/strong&gt; dimensions attach to one event only — the next event from the same user will not inherit the value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-scoped&lt;/strong&gt; dimensions attach to the user profile and persist across every future event until overwritten — right for stable attributes like subscription tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Item-scoped&lt;/strong&gt; dimensions live inside the items array of ecommerce events and describe individual products.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note that session scope is not available for custom dimensions in GA4. If you genuinely need a value to apply to every event in a session, you have to send it with each event yourself. Getting scope right at the design stage saves hours of debugging, since a mis-scoped dimension fails quietly rather than throwing an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits you must plan around
&lt;/h2&gt;

&lt;p&gt;These limits are per property — not per account or per stream — and easy to hit on a complex setup. One important nuance: deleting an event-scoped dimension frees the slot after a 48-hour wait, but archiving a user-scoped dimension does &lt;strong&gt;not&lt;/strong&gt; free its slot, so the 25 user-scoped definitions are effectively fixed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Standard property&lt;/th&gt;
&lt;th&gt;Analytics 360&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Event-scoped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;Slot frees 48h after deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User-scoped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Archiving does NOT free a slot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Item-scoped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;Explorations only, not standard reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom metrics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;Numeric values only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Calculated metrics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Built from numeric metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to set up a custom dimension, step by step
&lt;/h2&gt;

&lt;p&gt;The order of sending and registering does not strictly matter, but do both at roughly the same time — a gap means missing data for that window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — send the parameter
&lt;/h3&gt;

&lt;p&gt;Push the value into the data layer, then read it into your GA4 event tag in GTM. For a blog where you want to know which category drives engagement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;article_view&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;article_category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;analytics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;author_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editorial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In GTM, create Data Layer Variables for &lt;code&gt;article_category&lt;/code&gt; and &lt;code&gt;author_name&lt;/code&gt;, then add them as parameters on your GA4 event tag. The parameter name you send must match the name you register exactly — &lt;code&gt;article_category&lt;/code&gt; is not the same as &lt;code&gt;articleCategory&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — register it in GA4 Admin
&lt;/h3&gt;

&lt;p&gt;Go to &lt;strong&gt;Admin → Custom definitions → Custom dimensions → Create custom dimension&lt;/strong&gt;. Enter a Dimension name (the readable label in reports, e.g. "Article Category"), pick the Scope (Event), and type the Event parameter name exactly as it arrives (&lt;code&gt;article_category&lt;/code&gt;). Save. That registration tells GA4 — and connected tools like Looker Studio — to expect the parameter and surface it as a dimension.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — verify in DebugView, then wait
&lt;/h3&gt;

&lt;p&gt;Before trusting anything, open DebugView and confirm the parameter is arriving with the right value on the right event. A common agency mistake is registering a dimension before confirming the event actually fires. Once verified, data takes 24–48 hours to populate in standard reports — DebugView is immediate, reports are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real examples by scope
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Event-scoped: content category and form name
&lt;/h3&gt;

&lt;p&gt;Event-scoped is right for data that describes a single action. Sending &lt;code&gt;article_category&lt;/code&gt; on an &lt;code&gt;article_view&lt;/code&gt; lets you see which topics drive engagement. Sending &lt;code&gt;form_name&lt;/code&gt; on a &lt;code&gt;form_submit&lt;/code&gt; lets you compare your newsletter form against your contact form. Both describe that one interaction and should not persist — which is exactly what event scope does.&lt;/p&gt;

&lt;h3&gt;
  
  
  User-scoped: membership tier
&lt;/h3&gt;

&lt;p&gt;For stable attributes that should follow the user everywhere, use a user property. Set it on the first event after login, when you have the identity context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;set&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_properties&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;membership_tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;premium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every subsequent event — page views, purchases, conversions — carries &lt;code&gt;membership_tier&lt;/code&gt;, so you can segment any report by plan. The classic mistake is sending this as an event parameter on the login event only, which leaves every later event blank.&lt;/p&gt;

&lt;h3&gt;
  
  
  Item-scoped: product attributes
&lt;/h3&gt;

&lt;p&gt;Item scope answers product-level questions like revenue per colour or margin tier. The custom parameters go inside each object in the items array of an ecommerce event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add_to_cart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ecommerce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SKU_12345&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;item_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Running Shoes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;item_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;margin_tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember that item-scoped dimensions only appear in Explorations, not standard reports. If you are wiring up ecommerce tracking from scratch, the &lt;a href="https://trackstack.tech/en/ga4-ecommerce-events-setup-guide/" rel="noopener noreferrer"&gt;GA4 ecommerce events&lt;/a&gt; guide covers the full items array first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes that silently break your data
&lt;/h2&gt;

&lt;p&gt;The most dangerous mistakes produce no error — your dimension just stays empty or wrong while you assume it works.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong scope:&lt;/strong&gt; event scope for something stable like membership tier (data only on one event), or user scope for something transient like a search term (every future event inherits the wrong value).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name mismatch:&lt;/strong&gt; the registered name and the sent parameter must be identical, character for character.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passing PII:&lt;/strong&gt; never send emails, phone numbers, or names as dimension values — it violates Google's Terms and can get your property permanently deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High cardinality:&lt;/strong&gt; dimensions with thousands of unique values (full URLs, user IDs, timestamps) overflow into an aggregated &lt;code&gt;(other)&lt;/code&gt; row and become useless — keep values to a bounded set of categories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hoarding slots:&lt;/strong&gt; registering every parameter you ever send. Audit quarterly and retire anything not queried in 90 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also remember the 100-character value limit (with a few exceptions like &lt;code&gt;page_location&lt;/code&gt; at 1,000), and that raw exports keep everything regardless — so if you need full history or unregistered fields, lean on BigQuery or &lt;a href="https://trackstack.tech/en/server-side-tracking-when-needed-what-it-costs/" rel="noopener noreferrer"&gt;server-side tracking&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which scope should you use?
&lt;/h2&gt;

&lt;p&gt;Work backwards from the question you want to answer rather than from the data you happen to have. If the answer is "per action," reach for event scope; "per person," user scope; "per product," item scope.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Event scope&lt;/strong&gt; — the value describes a single action: content category, button label, form name, video title.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User scope&lt;/strong&gt; — the value is a stable trait of the person: subscription tier, customer type, company size, signup source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Item scope&lt;/strong&gt; — the value describes a product inside an ecommerce event: colour, size, brand, margin tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom metric instead&lt;/strong&gt; — the value is numeric and you want to sum or average it: loyalty points, scores, credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing&lt;/strong&gt; — if GA4 already captures it; check the built-in dimensions before spending a slot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Custom dimensions are what turn GA4 from a generic report into a tool that answers your specific questions — but only if you get the fundamentals right. Send the parameter and register it together, pick the scope deliberately, respect the per-property limits, verify in DebugView, and never pass PII. Start with two or three high-value dimensions rather than registering everything, keep a small register of what each one is for, and audit quarterly.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are GA4 custom dimensions retroactive?&lt;/strong&gt;&lt;br&gt;
No. Data only appears from the moment you register the dimension forward — there is no backfill. If you send a parameter for a week before registering it, reports will miss that week. Raw data streamed to BigQuery keeps the parameter regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between an event parameter and a custom dimension?&lt;/strong&gt;&lt;br&gt;
The parameter is the key-value data sent with an event at collection time. The custom dimension is the GA4 Admin registration that maps that parameter to a readable name and scope, making it visible in reports. Without registration the parameter exists in raw data but not in the GA4 interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many custom dimensions can I create?&lt;/strong&gt;&lt;br&gt;
On a standard property: 50 event-scoped, 25 user-scoped, 10 item-scoped, plus 50 custom metrics, all per property. Analytics 360 raises these to 125, 100, and 25. Deleting an event-scoped dimension frees its slot after 48 hours; archiving a user-scoped one does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my custom dimension showing no data?&lt;/strong&gt;&lt;br&gt;
Usually one of three reasons: the parameter name does not match the registered name exactly, the event is not actually firing (check DebugView), or you registered it less than 24–48 hours ago. Item-scoped dimensions also only show in Explorations, not standard reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should membership tier be event-scoped or user-scoped?&lt;/strong&gt;&lt;br&gt;
User-scoped. It is a stable attribute that should follow the user across every event, so set it as a user property after login. As an event parameter on the login event only, it will be blank on all later events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I send personal data as a custom dimension?&lt;/strong&gt;&lt;br&gt;
No. Passing emails, phone numbers, full names, or other PII violates Google's Terms of Service and can result in your property being permanently deleted. Use non-identifying values like tiers, categories, or hashed identifiers.&lt;/p&gt;

&lt;p&gt;A GA4 custom dimension = an event parameter (or user property) you send + a registration in GA4 Admin that maps it to a name and scope. Both are required, and registration is not retroactive (no backfill). Pick scope by the question: per action → event, per person → user, per product → item. Limits per property: 50 event / 25 user / 10 item / 50 metrics — and archiving a user-scoped one never frees the slot. Match parameter names exactly, verify in DebugView, wait 24–48h, and never send PII.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Coda vs Notion: Which Is Better for Your Workspace? (2026)</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Wed, 24 Jun 2026 04:42:42 +0000</pubDate>
      <link>https://dev.to/trackstack/coda-vs-notion-which-is-better-for-your-workspace-2026-3mfi</link>
      <guid>https://dev.to/trackstack/coda-vs-notion-which-is-better-for-your-workspace-2026-3mfi</guid>
      <description>&lt;p&gt;Both Coda and Notion promise the same thing — one workspace to replace your wiki, project tracker, and lightweight database — but they get there from opposite directions. Notion is document-first: everything is a block on a page, and you build outward. Coda is app-first: every doc can behave like a small application, with spreadsheet-grade formulas and automations baked in. The label "all-in-one workspace" hides a real fork in the road.&lt;/p&gt;

&lt;p&gt;This &lt;strong&gt;Coda vs Notion&lt;/strong&gt; comparison is for SMB teams and operators deciding where to centralise their work in 2026 — the genuine difference in philosophy, the pricing models that can swing your annual bill by thousands, where each wins and frustrates, and a clear framework for choosing (including the team-structure question most comparisons skip).&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer: Coda or Notion?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Notion&lt;/strong&gt; is the better default for most teams: easier to learn, document-first, stronger AI agents, native offline support, and a vast template ecosystem. &lt;strong&gt;Coda&lt;/strong&gt; wins when you need spreadsheet-grade formulas, app-like automation, and — crucially — when you have many content consumers but few creators, because its maker billing only charges the people who build docs. Pick Notion for clarity, writing, and fast adoption; pick Coda for logic, automation, and real cost savings on consumer-heavy teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core difference: documents vs applications
&lt;/h2&gt;

&lt;p&gt;Before pricing or features, understand the philosophy, because it shapes everything else. Notion wants documents that are easy and forgiving; Coda wants documents that behave like software. It comes down to a simple question: do you need a document you read and edit, or a document that calculates and does things for you? Confusing those two needs is the main reason teams pick the wrong tool and migrate six months later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Notion is built for.&lt;/strong&gt; Notion treats the page as the unit. You drag text, headings, databases, and embeds into any layout, and most people are productive within 15 minutes. It is strongest as a knowledge hub — wikis, notes, and lightweight databases — and shines when documentation and tasks live together; if your main goal is a searchable &lt;a href="https://trackstack.tech/en/company-knowledge-base-structure-access-search/" rel="noopener noreferrer"&gt;internal knowledge base&lt;/a&gt;, it is the natural fit. Its permission system is granular (five levels from full access to comment-only), so you can protect a table's structure while still letting people add rows. Founded in 2013, it has had years to polish the editing experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Coda is built for.&lt;/strong&gt; Coda treats the doc as a programmable surface. Its tables are full spreadsheets with a formula engine that rivals Excel (IF, VLOOKUP, SUMIF), plus buttons that trigger automations and Packs that sync two-way with tools like Slack, Jira, and Google Calendar. It also includes native time tracking, which Notion lacks. The payoff is power; the price is a steeper learning curve of 30–60 minutes. Coda also offers unlimited guest access by email on paid tiers, where Notion caps guests at 100 on Plus and 250 on Business — useful for agencies juggling many clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026 pricing: the maker-billing story
&lt;/h2&gt;

&lt;p&gt;Pricing is the single biggest practical difference, and it is not about the sticker price — it is about who pays. Notion charges per seat: every user costs the same whether they write or only read. Coda uses maker billing: only Doc Makers (people who create docs) pay, while editors and viewers are free. That structural gap can swing a large team's annual bill by a five-figure number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notion&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; unlimited pages for individuals; block limits for 2+ member workspaces; 5MB file uploads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plus:&lt;/strong&gt; $10/user/month annual ($12 monthly)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business:&lt;/strong&gt; $20/user/month annual ($24 monthly) — includes full Notion AI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise:&lt;/strong&gt; custom — SAML SSO, SCIM, advanced controls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notion AI is now bundled into paid plans rather than a separate add-on, with a credit pool per plan. The catch: full AI sits on the Business tier, so unlocking it means upgrading every seat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coda&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; unlimited docs, tables, and automations, but with object/row caps per doc&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro:&lt;/strong&gt; $10/Doc Maker/month annual — unlimited free editors, 2,000 AI credits per maker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team:&lt;/strong&gt; $30/Doc Maker/month annual — advanced admin, 6,000 AI credits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise:&lt;/strong&gt; custom&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Which is actually cheaper.&lt;/strong&gt; Count your builders versus consumers. If everyone creates docs, Notion is usually cheaper — Business at $20 beats Coda Team at $30 per maker. But if a few build and many only read, Coda wins big: a 100-person company with 15 Doc Makers pays roughly $450/month on Coda Team versus about $2,000/month for Notion Business at the same headcount. Coda reports teams saving up to 78% when only a fraction create docs. Watch for "maker creep," though — depending on setup, someone who just wants to add a table row may need maker status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Notion&lt;/th&gt;
&lt;th&gt;Coda&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Approach&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Document-first&lt;/td&gt;
&lt;td&gt;App-like / formula-driven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Billing model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per user (all pay)&lt;/td&gt;
&lt;td&gt;Per Doc Maker (editors free)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Entry paid price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$10/user/mo&lt;/td&gt;
&lt;td&gt;$10/maker/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Formulas / automation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Spreadsheet-grade, strong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ease of adoption&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~15 min&lt;/td&gt;
&lt;td&gt;30–60 min learning curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agents, bundled in plans&lt;/td&gt;
&lt;td&gt;Included for makers (credits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Offline support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (desktop)&lt;/td&gt;
&lt;td&gt;Limited (web-first)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When to choose each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Notion for knowledge and writing.&lt;/strong&gt; A team maintaining handbooks, onboarding guides, meeting notes, and a wiki is better served by Notion. Its editor is more pleasant for long-form writing, adoption is fast, and the template ecosystem covers nearly every workflow — it is also the easier base for &lt;a href="https://trackstack.tech/en/how-to-use-notion-as-a-lightweight-crm-2026-step-by-step/" rel="noopener noreferrer"&gt;running Notion as a lightweight CRM&lt;/a&gt; without much setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Coda for interactive, data-rich systems.&lt;/strong&gt; If your documentation needs to calculate, automate, or behave like an app — live dashboards, OKR systems, approval flows, or API docs with automated changelogs — Coda does things Notion cannot. A practical test: if the same content would work as a static page, Notion is enough; if it needs to react to data (recalculating a metric, flipping a status, notifying Slack when a field changes), that is where Coda earns its complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose by team structure and budget.&lt;/strong&gt; This is the deciding factor many people miss. A large team with a handful of builders and a wide circle of viewers should lean Coda for the maker-billing savings. A team where everyone creates content should lean Notion, where flat per-seat pricing is simpler and often cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where Notion frustrates.&lt;/strong&gt; Its flexibility invites over-engineering — teams build sprawling, messy workspaces. Its formulas are basic next to Coda's, deeper automation leans on third-party tools like Zapier, and performance degrades past roughly 10 concurrent editors on one page. Full AI also requires the Business tier across every seat. Search gets harder as the workspace grows into hundreds of pages, and without discipline the wiki becomes a place where everything exists but nothing is findable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where Coda frustrates.&lt;/strong&gt; Its power comes with complexity; non-technical teammates often need tutorials before they are productive. Offline support is limited because the app is web-first, and the free plan's per-doc object caps bite quickly. There is also a strategic question: Coda is now owned by Superhuman (formerly Grammarly), and its AI roadmap has folded into that suite — a different priority list than picking Coda standalone, which makes betting on a specific future feature riskier than with Notion's single roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose: a simple framework
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose Notion&lt;/strong&gt; for a polished knowledge hub, easy adoption, strong AI agents, and offline access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose Coda&lt;/strong&gt; for spreadsheet-grade formulas, app-like automation, native time tracking, or cross-doc logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose Coda&lt;/strong&gt; if you have many viewers and few creators — maker billing saves significantly at scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose Notion&lt;/strong&gt; if everyone on the team creates content, since flat per-seat pricing is simpler and often cheaper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid Coda&lt;/strong&gt; if your team is non-technical and needs fast, frictionless adoption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid Notion&lt;/strong&gt; if your core need is interactive, calculation-heavy documents rather than writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Coda versus Notion is not a contest with one winner — it is a choice between two philosophies. Notion is better for clarity, writing, and rapid adoption, and it is the safer default for most teams. Coda is better for automation, structure, and system-building, and its maker billing makes it dramatically cheaper for teams with many consumers and few creators. The smartest move is the oldest one: build a real workflow in each on the free plan, count your builders versus consumers, and pick the tool that fits how your team actually works. (If AI is central to that decision, it is worth weighing &lt;a href="https://trackstack.tech/en/notion-ai-vs-chatgpt-note-taking-2026/" rel="noopener noreferrer"&gt;Notion AI vs ChatGPT&lt;/a&gt; separately too.)&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Coda cheaper than Notion?&lt;/strong&gt;&lt;br&gt;
It depends on team structure. Coda only charges Doc Makers, so if you have many viewers and few creators, it is much cheaper — sometimes 50–80% less. But if everyone creates documents, Notion's flat per-seat pricing usually wins, with Business at $20 undercutting Coda Team at $30 per maker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Notion or Coda easier to learn?&lt;/strong&gt;&lt;br&gt;
Notion, clearly. Its block-based editor and huge template library get most users productive within 15 minutes. Coda is more powerful but demands a 30–60 minute investment in tables, Packs, and formulas before it feels comfortable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Coda have better automation than Notion?&lt;/strong&gt;&lt;br&gt;
Yes. Coda has spreadsheet-grade formulas, in-doc buttons, and Packs with real two-way sync, so documents can act like applications. Notion's formulas are more basic and deeper automation usually relies on third-party tools like Zapier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which has better AI in 2026?&lt;/strong&gt;&lt;br&gt;
Notion has the more coherent AI story: AI is bundled across paid plans and now includes agents that run multi-step actions across connected tools. Coda AI inside the doc is excellent for formula and data work, but its broader AI roadmap has shifted into the Superhuman suite after the acquisition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use either tool for personal productivity?&lt;/strong&gt;&lt;br&gt;
Both work, but Notion is the better personal pick for notes, task tracking, and a "second brain," thanks to its simplicity and template ecosystem. Coda can work personally for advanced databases or mini-apps, but it is often overkill for simple personal workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Notion or Coda work offline?&lt;/strong&gt;&lt;br&gt;
Notion has a desktop app with offline support, so you can view and edit cached pages without internet. Coda is web-first with limited offline capability, which makes Notion the better choice for people who travel or work with unreliable connectivity.&lt;/p&gt;

&lt;p&gt;Notion is document-first and billed per seat; Coda is app-first (spreadsheet-grade formulas, automations, Packs) and billed per Doc Maker, with editors and viewers free. Notion wins on ease, AI agents, offline, and templates — the safer default. Coda wins on formulas, automation, and cost when you have many consumers and few creators (up to ~78% cheaper at scale). Count builders vs consumers, trial both free, and pick by team structure — not by which logo you know.&lt;/p&gt;

</description>
      <category>notion</category>
      <category>productivity</category>
      <category>tools</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Loom vs Zoom: When to Use Asynchronous Video (2026)</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:29:01 +0000</pubDate>
      <link>https://dev.to/trackstack/loom-vs-zoom-when-to-use-asynchronous-video-2026-20no</link>
      <guid>https://dev.to/trackstack/loom-vs-zoom-when-to-use-asynchronous-video-2026-20no</guid>
      <description>&lt;p&gt;Half the meetings on a calendar could have been a recorded video, and half the recorded videos teams send could have been a paragraph of text. The trick is knowing which is which. &lt;strong&gt;Loom&lt;/strong&gt; and &lt;strong&gt;Zoom&lt;/strong&gt; get compared as if they were rivals, but they solve opposite problems: Loom is asynchronous — record once, watched whenever — while Zoom is synchronous, everyone present at the same time. The real question is not which tool is better; it is when each mode of communication is the right one.&lt;/p&gt;

&lt;p&gt;This is for SMB owners, team leads, and remote-first managers trying to cut meeting bloat without losing the conversations that genuinely need to be live. Below: real 2026 pricing for both, the scenarios where each shines, the honest downsides of each, and a simple framework for choosing between a message, a recording, and a meeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer: Loom or Zoom?
&lt;/h2&gt;

&lt;p&gt;Use &lt;strong&gt;Loom&lt;/strong&gt; when information flows one way and timing does not matter — status updates, walkthroughs, onboarding, code or design reviews, client explainers. The viewer watches on their own schedule, at their own speed, and nobody hunts for a slot on three calendars. Use &lt;strong&gt;Zoom&lt;/strong&gt; when real-time back-and-forth is required: brainstorming, negotiation, sensitive conversations, interviews, or any decision that needs live debate.&lt;/p&gt;

&lt;p&gt;In short: Loom replaces the meeting that should not exist; Zoom protects the meeting that should. Most teams need both, and the money question is smaller than it looks because Loom charges per recorder while Zoom charges per host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async vs sync: the distinction that actually matters
&lt;/h2&gt;

&lt;p&gt;Before comparing features, get the mental model right. Synchronous communication is expensive because it costs everyone the same hour at the same time. Asynchronous communication is cheap because each person spends their own time when it suits them. The skill is matching the channel to the message, not defaulting to a meeting out of habit.&lt;/p&gt;

&lt;p&gt;A practical rule: if someone needs to decide something &lt;em&gt;together, right now&lt;/em&gt;, that is sync. If they can react in an hour or tomorrow, that is async — and a recording or text will beat a call.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Loom is built for
&lt;/h3&gt;

&lt;p&gt;Loom is a video messaging tool acquired by Atlassian in 2023. You record your screen and camera, it generates a shareable link instantly, and the recipient watches without an account. It is ideal for "let me show you" moments — clumsy in text, but not worth a live call. Recordings become a searchable library, which quietly turns Loom into part of an &lt;a href="https://trackstack.tech/en/company-knowledge-base-structure-access-search/" rel="noopener noreferrer"&gt;internal knowledge base&lt;/a&gt; rather than a one-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Zoom is built for
&lt;/h3&gt;

&lt;p&gt;Zoom is the default for live video — meetings, webinars, interviews, real-time collaboration. Its strength is presence: reading reactions, interrupting, deciding together in one sitting. If you are weighing it against other live tools, trackstack.tech has a deeper &lt;a href="https://trackstack.tech/en/zoom-vs-google-meet-vs-teams-comparison/" rel="noopener noreferrer"&gt;Zoom vs Google Meet vs Teams&lt;/a&gt; breakdown. For anything that needs genuine dialogue, async video is a poor substitute and Zoom is the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026 pricing, plan by plan
&lt;/h2&gt;

&lt;p&gt;The pricing models are structurally different, and that difference often decides the budget more than the per-seat number. Loom charges per &lt;em&gt;Creator&lt;/em&gt; — the people who record — and viewers are free forever. Zoom charges per host license. A team where one person records explainers for forty viewers pays very differently across the two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loom pricing in 2026
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Starter (Free):&lt;/strong&gt; $0 — up to 25 videos per creator, 5-minute cap per video, 720p&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business:&lt;/strong&gt; $15/creator/month annual (or $18 monthly) — unlimited videos and length, up to 4K, remove Loom branding, viewer analytics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business + AI:&lt;/strong&gt; $20/creator/month annual (or $24 monthly) — AI titles, summaries, chapters, filler-word and silence removal, edit by transcript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise:&lt;/strong&gt; custom — SSO (SAML), SCIM, advanced retention, Salesforce integration, 99.95% SLA&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key cost lever: only recorders need a seat. If two of five teammates record regularly, you pay for two creators (about $360/year on Business annual) while the rest watch free. That viewer-free model is what makes Loom cheap for outbound explainers and expensive only when everyone records.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zoom pricing in 2026
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Basic (Free):&lt;/strong&gt; $0 — unlimited 1:1 meetings, group meetings capped at 40 minutes, up to 100 participants, no cloud recording&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro:&lt;/strong&gt; $13.33/user/month annual (or $16.99 monthly) — 30-hour meetings, 100 participants, 10GB cloud storage, AI Companion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business:&lt;/strong&gt; $18.33/user/month annual (or $21.99 monthly) — 300 participants, SSO, managed domains, recording transcripts, 10-seat minimum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business Plus:&lt;/strong&gt; $29/user/month — bundles Zoom Phone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise:&lt;/strong&gt; custom pricing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The famous gate is the 40-minute cap on free group meetings — the single most common reason teams upgrade to Pro. Watch the cloud-storage trap too: recordings pile up, and once you exceed the included allowance Zoom charges roughly $10/month per extra 30GB, billed every month the files stay there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Loom (async)&lt;/th&gt;
&lt;th&gt;Zoom (sync)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Communication mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One-way, watch anytime&lt;/td&gt;
&lt;td&gt;Real-time, everyone present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Billing model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per creator (viewers free)&lt;/td&gt;
&lt;td&gt;Per host license&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Entry paid price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$15/creator/mo&lt;/td&gt;
&lt;td&gt;$13.33/user/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free tier limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;25 videos, 5-min cap&lt;/td&gt;
&lt;td&gt;40-min group meetings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduling needed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Yes (calendars align)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timezone-friendly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Poor across wide gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Walkthroughs, updates, reviews&lt;/td&gt;
&lt;td&gt;Debate, decisions, sensitive talks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When async video wins (reach for Loom)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Onboarding and repeatable training.&lt;/strong&gt; Record the setup walkthrough once and every new hire watches it on day one — no repeated live sessions. Pair recordings with written docs and a &lt;a href="https://trackstack.tech/en/task-management-system-priorities-sla-templates/" rel="noopener noreferrer"&gt;task management system&lt;/a&gt; so onboarding has both the "how" on video and the checklist to follow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code, design, and document review.&lt;/strong&gt; Talking through a pull request or a mockup while pointing at the screen carries nuance that inline comments lose, and the author watches when they reach that task rather than dropping everything for a call. This fits how &lt;a href="https://trackstack.tech/en/jira-vs-clickup-vs-asana-dev-teams/" rel="noopener noreferrer"&gt;dev teams run their workflow&lt;/a&gt;, where context matters but interrupt-driven meetings kill focus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async standups across timezones.&lt;/strong&gt; For a team split across five hours of timezone gap, a daily live standup is a tax on someone. A 90-second recorded update each morning keeps everyone informed without forcing anyone awake at an awkward hour — a core habit for &lt;a href="https://trackstack.tech/en/best-project-management-tools-remote-teams-2026/" rel="noopener noreferrer"&gt;remote teams&lt;/a&gt; that run distributed by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client explainers and sales follow-ups.&lt;/strong&gt; A personalised two-minute walkthrough sent to a prospect lands better than another email and needs no booked call. Because viewers do not need a Loom account, this is genuinely frictionless for outbound — one of the clearest wins of per-creator pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a live call wins (reach for Zoom)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Decisions that need real debate.&lt;/strong&gt; When a topic has competing opinions and trade-offs to resolve, the back-and-forth of a live call reaches a conclusion in twenty minutes that a thread of recordings would drag out over two days. Branching discussions need interruption, and async cannot interrupt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitive or high-stakes conversations.&lt;/strong&gt; Feedback, conflict, negotiation, and anything emotionally loaded belongs live. A recording cannot read the room or adjust mid-sentence when someone reacts. Sending a difficult message as a one-way video usually makes it worse, not faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interviews, kickoffs, and relationship-building.&lt;/strong&gt; First meetings, hiring interviews, and project kickoffs benefit from rapport that only happens live. These are the moments where presence is the point, and trimming them to async saves time nobody wanted to save.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where Loom frustrates.&lt;/strong&gt; Async has no immediacy — if you need an answer now, a recording is the wrong tool. Loom also tempts overuse: people record three-minute videos for things a two-line message would settle, which just moves the bloat from meetings to playback. And the per-creator cost climbs once the whole team records regularly, so it is not automatically the cheap option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where Zoom frustrates.&lt;/strong&gt; Meeting fatigue is real, scheduling across timezones is constant friction, and the 40-minute free cap nudges you to pay quickly. Cloud-recording storage costs creep up if you record often, and a calendar packed with calls that should have been recordings is the exact problem async video exists to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose: the escalation ladder
&lt;/h2&gt;

&lt;p&gt;Default to the lightest channel that does the job, and only escalate when it genuinely needs more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text first&lt;/strong&gt; — if it fits in a few lines and needs no visuals, send a message, not a video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loom (async video) next&lt;/strong&gt; — when you need to &lt;em&gt;show&lt;/em&gt; something, explain a process, or add tone, but no live reply is required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zoom (live) last&lt;/strong&gt; — when the topic needs real-time debate, is sensitive, or involves a decision with trade-offs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose Loom&lt;/strong&gt; if your team is distributed, your messages are mostly one-way, and only a few people record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose Zoom&lt;/strong&gt; if your work is collaborative and decision-heavy, or you run interviews and client calls regularly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid recording&lt;/strong&gt; anything urgent, emotional, or genuinely two-way — that is a call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Loom versus Zoom is not a contest with a winner — it is a choice between two modes of communication, and the mature answer is to use both deliberately. Loom kills the meeting that should never have been scheduled; Zoom protects the conversation that genuinely needs everyone in the room. Get the async-versus-sync instinct right and you reclaim hours a week without losing the dialogue that matters. Audit your calendar for a fortnight, move the one-way meetings to recordings, and keep the rest live.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Loom a replacement for Zoom?&lt;/strong&gt;&lt;br&gt;
No — they cover different needs. Loom handles one-way, watch-anytime communication; Zoom handles real-time meetings. Many teams use both: Loom to cut unnecessary meetings, Zoom for discussions that truly need to be live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Loom free for viewers?&lt;/strong&gt;&lt;br&gt;
Yes. Loom charges per creator — the people who record — and viewers watch shared links for free without an account. That makes it cost-effective when only a few teammates record but many people watch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Zoom cut my meeting off at 40 minutes?&lt;/strong&gt;&lt;br&gt;
The 40-minute limit applies to group meetings on the free Basic plan. One-on-one meetings are unlimited. Upgrading to Pro at $13.33 per user per month (annual) removes the cap and extends meetings to 30 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I record meetings in Zoom instead of using Loom?&lt;/strong&gt;&lt;br&gt;
You can, but a Zoom recording is a captured live meeting, not a purpose-built async message. Loom is faster for quick screen walkthroughs, generates instant shareable links, and keeps a searchable library, whereas Zoom recordings are heavier and consume cloud storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which is cheaper for a small team?&lt;/strong&gt;&lt;br&gt;
It depends on who records or hosts. Loom only bills the people who record, so a team where two people make videos for everyone is cheap. Zoom bills per host, so the cost tracks how many people run meetings, not how many attend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does asynchronous video actually save time?&lt;/strong&gt;&lt;br&gt;
It does when it replaces a meeting that did not need everyone live, because viewers watch on their own schedule and often at faster speed. It saves nothing if it replaces a message that should have stayed text, so match the channel to the task.&lt;/p&gt;

&lt;p&gt;Loom is asynchronous (record once, watched anytime, billed per recorder); Zoom is synchronous (live, billed per host). Use Loom for one-way work — walkthroughs, onboarding, reviews, async standups, client explainers — and Zoom for real-time work: debate, sensitive talks, interviews, decisions. Escalate deliberately: text → Loom → Zoom. Most teams need both; getting the async-vs-sync call right is what reclaims hours.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>remote</category>
      <category>tools</category>
      <category>async</category>
    </item>
    <item>
      <title>Build a CRM Backend on Notion's API in 2026: $5/Month Stack with Node.js</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Sat, 06 Jun 2026 16:02:01 +0000</pubDate>
      <link>https://dev.to/trackstack/build-a-crm-backend-on-notions-api-in-2026-5month-stack-with-nodejs-4d37</link>
      <guid>https://dev.to/trackstack/build-a-crm-backend-on-notions-api-in-2026-5month-stack-with-nodejs-4d37</guid>
      <description>&lt;p&gt;The full version of this article teaches non-developers how to configure Notion's UI into a working CRM. This is the developer take: &lt;strong&gt;don't configure the UI — automate around the API.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;If you're a non-dev founder, read the &lt;a href="https://trackstack.tech/en/how-to-use-notion-as-a-lightweight-crm-2026-step-by-step/" rel="noopener noreferrer"&gt;full version&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Website form → Notion contact
&lt;/h2&gt;

&lt;p&gt;Create a Notion integration at &lt;code&gt;notion.so/profile/integrations&lt;/code&gt;, copy the secret, and share your Contacts database with it. Database ID comes from the URL (the 32-char hex after the workspace name).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@notionhq/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CONTACTS_DB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_CONTACTS_DB_ID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/lead&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Verify the source — your form should sign the payload&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-form-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FORM_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;utm_source&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Dedupe by email — query existing first&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;database_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONTACTS_DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Update last-contacted on the existing contact&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;page_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Last Contacted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Create new contact&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;database_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONTACTS_DB&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rich_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new lead&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;utm_source&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Last Contacted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rich_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop this on a Hetzner/DO/Linode $5-6/month VPS, point your form's webhook at &lt;code&gt;https://crm.yourdomain.com/lead&lt;/code&gt;, slap Caddy in front for TLS. Your website form now creates Notion contacts with deduplication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dedupe-by-email pattern&lt;/strong&gt; is the single most useful thing here — without it, retries and double-submissions clutter your database with duplicates within a week. Always upsert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Notion webhook → Slack on stage change
&lt;/h2&gt;

&lt;p&gt;Notion shipped first-party webhooks in late 2024 — finally usable for reactive automations. Subscribe via the API to events on your Deals database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One-time setup: register a webhook for the Deals database&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.notion.com/v1/webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Notion-Version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2022-06-28&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://crm.yourdomain.com/notion-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;event_types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page.properties_updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;database_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_DEALS_DB_ID&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then handle the events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/notion-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Verify Notion's signature&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-notion-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`sha256=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page.properties_updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;page_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;select&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dealName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Deal Name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;plain_text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;won&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`🎉 Deal won: *&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dealName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;* — $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Stale lead detector (the cron that actually saves leads)
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// stale-leads.js — run via cron @ 9am daily&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@notionhq/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;THIRTY_DAYS_AGO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findStaleLeads&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;database_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_CONTACTS_DB_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;and&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new lead&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Last Contacted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;THIRTY_DAYS_AGO&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;start_cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has_more&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next_cursor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findStaleLeads&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;plain_text&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unnamed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastContacted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Last Contacted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`• *&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;* (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) — last touched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lastContacted&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`☕ Morning. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; stale leads (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top 10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;):\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;crontab -e&lt;/code&gt; and add &lt;code&gt;0 9 * * * /usr/bin/node /home/crm/stale-leads.js&lt;/code&gt;. Every morning at 9, your Slack gets a digest of leads who need a nudge.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limits and pagination math
&lt;/h2&gt;

&lt;p&gt;Notion's API is rate-limited to &lt;strong&gt;~3 requests per second&lt;/strong&gt; average per integration. Burst above that and you'll get &lt;code&gt;rate_limited&lt;/code&gt; errors. Database queries return max 100 results per page; full-database scans need pagination.&lt;/p&gt;

&lt;p&gt;For a CRM with ~500 contacts and ~100 active deals, every operation is well within limits. The numbers that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Form submission&lt;/strong&gt;: 2 API calls (query for dedupe + create/update). At 100 leads/day = 200 calls = 6,000/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook handler&lt;/strong&gt;: 1 API call per event (retrieve page). At 50 stage changes/day = 1,500 calls/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily stale-lead cron&lt;/strong&gt;: ~5-10 paginated queries. ~300 calls/month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where you'd hit limits:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stack breaks down
&lt;/h2&gt;

&lt;p&gt;Honest list — when it's time to graduate to a real CRM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email integration.&lt;/strong&gt; 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 &amp;gt;1k subscribers, use a real ESP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-step sequences.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team scaling.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting trust.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For graduation paths, our &lt;a href="https://trackstack.tech/en/pipedrive-vs-hubspot-b2b/" rel="noopener noreferrer"&gt;Pipedrive vs HubSpot for B2B&lt;/a&gt; comparison and &lt;a href="https://trackstack.tech/en/best-crm-for-small-business-2026-2/" rel="noopener noreferrer"&gt;best CRM for small business&lt;/a&gt; roundup are the natural reads.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The full stack:&lt;/strong&gt; Notion (Free) + $5-6/month VPS + Caddy + ~80 lines of Node.js. Total infra: ~$5-10/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you get:&lt;/strong&gt; dedup-on-submit, webhook-driven Slack notifications, stale-lead detection. The 90% of a real CRM that actually matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you don't get:&lt;/strong&gt; native email, sequences, sales-team reporting, deliverability tracking. Build these and you're rewriting HubSpot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it breaks:&lt;/strong&gt; past 500 contacts + email-heavy workflows + 5+ team members. Graduate at that point — don't fight it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/how-to-use-notion-as-a-lightweight-crm-2026-step-by-step/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the UI-first setup walkthrough and FAQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>node</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Build Your Own 'Notion AI' for $1/month: Notion API + OpenAI in 50 Lines</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Fri, 05 Jun 2026 05:37:50 +0000</pubDate>
      <link>https://dev.to/trackstack/build-your-own-notion-ai-for-1month-notion-api-openai-in-50-lines-6lo</link>
      <guid>https://dev.to/trackstack/build-your-own-notion-ai-for-1month-notion-api-openai-in-50-lines-6lo</guid>
      <description>&lt;p&gt;I was paying $20/month for ChatGPT Plus and considering Notion AI Business ($20/user/month) to get the workspace-aware AI features. Then I did the math: &lt;strong&gt;Notion's API is free, OpenAI's &lt;code&gt;gpt-4o-mini&lt;/code&gt; is $0.15 per million input tokens, and my actual usage is ~5,000 input tokens per query.&lt;/strong&gt; That's $0.00075 per "ask your notes" call. Even at 100 queries a month, I'd spend less than a dollar in API costs.&lt;/p&gt;

&lt;p&gt;This is the build-vs-buy take the SaaS comparison sites won't write. Below is the 50-line implementation, the real token math, where the DIY approach breaks down, and when to just pay the $20.&lt;/p&gt;

&lt;p&gt;For the SMB-perspective comparison (no code, full pricing breakdown, the 2025-2026 Notion AI restructuring), the &lt;a href="https://trackstack.tech/en/notion-ai-vs-chatgpt-note-taking-2026/" rel="noopener noreferrer"&gt;full version of this article&lt;/a&gt; covers the non-developer angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build-it-yourself stack
&lt;/h2&gt;

&lt;p&gt;Three pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Notion API&lt;/strong&gt; for the data layer — read your pages and their content. Free, well-documented, rate-limited at ~3 req/sec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI API&lt;/strong&gt; (or Anthropic) for the AI layer — send your notes as context, get answers. Cheap per token if you stay on &lt;code&gt;gpt-4o-mini&lt;/code&gt; or &lt;code&gt;claude-3-5-haiku&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A glue function&lt;/strong&gt; that combines them. ~50 lines, no framework needed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You replace "what Notion AI does" (workspace Q&amp;amp;A, summarisation, autofill) with code you own. You lose: the polished UI, inline rendering inside Notion pages, Custom Agents. You gain: ~95% lower cost, full prompt control, choice of model, and your data stops going through Notion's AI pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Pull notes from Notion
&lt;/h2&gt;

&lt;p&gt;Create a Notion integration at &lt;code&gt;notion.so/profile/integrations&lt;/code&gt;, share the pages you want to query with it, set the token as &lt;code&gt;NOTION_TOKEN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@notionhq/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchRecentPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;descending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;last_edited_time&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;block_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extractText&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rich&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;rich_text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rich&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rich&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plain_text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;titleProp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;titleProp&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;plain_text&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Untitled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real caveats:&lt;/strong&gt; the API returns blocks one level deep by default — nested blocks (toggles, callouts) need recursive fetching. Long pages with many blocks hit rate limits if you parallelise too aggressively. For 50+ pages, throttle to ~3 parallel requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Send notes as context to an LLM
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;askYourNotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Naive truncation — fine for ~50 pages, use embeddings beyond that&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`# &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Answer using ONLY the user notes provided. Cite page titles when relevant. If the answer is not in the notes, say so.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`My notes:\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nQuestion: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Use it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchRecentPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;askYourNotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;What did I decide about the Q4 roadmap?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire workspace-Q&amp;amp;A loop. Drop it in a CLI tool, a Slack bot, or an internal web app. Now you have what Notion AI does, minus the inline rendering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic version&lt;/strong&gt; if you prefer Claude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@anthropic-ai/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANTHROPIC_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-3-5-haiku-latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Notes:\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nQuestion: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both work. OpenAI's &lt;code&gt;gpt-4o-mini&lt;/code&gt; is the cheaper option per token; Claude's Haiku is slightly more expensive but I find its summaries more concise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The token math that justifies this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per query:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input: ~5,000 tokens of notes context + ~50 tokens of question + system prompt&lt;/li&gt;
&lt;li&gt;Output: ~500 tokens of answer&lt;/li&gt;
&lt;li&gt;gpt-4o-mini: 5,000 × $0.15/1M + 500 × $0.60/1M = &lt;strong&gt;$0.001 per query&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;claude-3-5-haiku: 5,000 × $0.80/1M + 500 × $4/1M = &lt;strong&gt;$0.006 per query&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At realistic usage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100 queries/month: $0.10 (OpenAI) or $0.60 (Anthropic)&lt;/li&gt;
&lt;li&gt;1,000 queries/month: $1 (OpenAI) or $6 (Anthropic)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Compared to:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Notion AI Business: $20/user/month&lt;/li&gt;
&lt;li&gt;ChatGPT Plus: $20/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap is 95-99% cheaper for OpenAI, 70-95% for Anthropic. Even at 5,000 queries/month (which is a lot) you're under $5 on OpenAI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; these numbers assume you're stuffing ~5,000 tokens of context per query. If you scale to a 500-page workspace and want full coverage, you need embeddings — embed each page once with &lt;code&gt;text-embedding-3-small&lt;/code&gt; ($0.02/1M tokens, basically free), store in pgvector or sqlite-vss, retrieve the top-N relevant pages per query. Adds maybe an hour of setup; cost stays under $5/month for personal use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the SaaS subscriptions still win
&lt;/h2&gt;

&lt;p&gt;Honest list — when paying for Notion AI Business or ChatGPT Plus is worth it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inline rendering inside Notion.&lt;/strong&gt; "AI block" that lives in the page, autofill in databases, summarise-on-the-page. You can't replicate this experience with an external script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polished UI for non-developers.&lt;/strong&gt; If you're recommending a tool to colleagues who don't write code, the SaaS UX wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT Pro's deep research, voice, image, web search.&lt;/strong&gt; None of these are workspace-aware AI features — they're separate value. ChatGPT Plus at $20 buys you the model + all those general-purpose AI features, not just note-taking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom Agents (Notion's 2026 feature).&lt;/strong&gt; If your workflow depends on agents that act on Notion data over time, the official integration is more reliable than DIY orchestration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No infra to maintain.&lt;/strong&gt; A $20/month SaaS bill is one line of book-keeping. Your DIY script needs a host, API keys to rotate, and occasional debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest framing: the SaaS pricing is mostly product, not raw AI compute. You pay for the UI, the integration, and someone else maintaining it.&lt;/p&gt;

&lt;h2&gt;
  
  
  ChatGPT's Notion connector — when to just use it
&lt;/h2&gt;

&lt;p&gt;If you don't want to write code but also don't want to pay for Notion AI Business, ChatGPT Plus ($20/month) added a &lt;strong&gt;Notion connector&lt;/strong&gt; in 2025 that lets it read your workspace. From inside ChatGPT, you can ask questions about your notes and it pulls relevant context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; zero setup beyond OAuth, works with all of ChatGPT's other features (web search, file analysis, image), uses GPT-5/4o quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; sends your notes to OpenAI for processing (data residency concern for some), works one-way (ChatGPT reads Notion, doesn't write back), the retrieval can miss pages you'd expect it to find.&lt;/p&gt;

&lt;p&gt;For a single user who already has ChatGPT Plus, the connector is the cheapest "good enough" option. For a team needing workspace-aware AI without paying Notion AI Business per seat, the DIY approach above scales better.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Notion AI Business ($20/user/mo)&lt;/strong&gt; and &lt;strong&gt;ChatGPT Plus ($20/mo)&lt;/strong&gt; both solve workspace-aware AI, at the same price point, very differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can wire Notion API + OpenAI &lt;code&gt;gpt-4o-mini&lt;/code&gt; for ~$1/month&lt;/strong&gt; of actual API costs. ~50 lines of code, no framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay for the SaaS when&lt;/strong&gt; inline rendering, polished UI, or Custom Agents matter more than the 95% cost saving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT's Notion connector is the middle ground&lt;/strong&gt; if you have Plus already and don't want to code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For teams of 5+&lt;/strong&gt; doing real note-taking AI work, DIY is genuinely $20–95/month cheaper than Notion AI Business at scale. Build it once, share across the team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern generalises beyond Notion — Linear API + OpenAI, Asana API + OpenAI, Slack API + OpenAI. Most "AI for $tool" SaaS features can be assembled from $tool's API plus a few cents of tokens per query. Worth knowing before you sign a recurring SaaS contract for a feature you could own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/notion-ai-vs-chatgpt-note-taking-2026/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the full SMB comparison including pricing reality and use cases.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Asana vs Monday for Developers: GraphQL, REST, and the Pricing Trap</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:41:28 +0000</pubDate>
      <link>https://dev.to/trackstack/asana-vs-monday-for-developers-graphql-rest-and-the-pricing-trap-i4b</link>
      <guid>https://dev.to/trackstack/asana-vs-monday-for-developers-graphql-rest-and-the-pricing-trap-i4b</guid>
      <description>&lt;p&gt;Most "Asana vs Monday" comparisons fight over pricing tiers and UI polish. For developers integrating with either, the more interesting question is: &lt;strong&gt;what does the API actually look like, and what's it like to maintain code against it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Short answer: Monday has GraphQL, Asana has REST, and that single architectural choice changes how you'll build on each. Below is the developer-facing comparison the SaaS review sites skip — with code samples, webhook patterns, and the per-seat math you only feel once your integration is in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API: GraphQL vs REST, and why it matters
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Monday: one of the few PM tools with a real GraphQL API
&lt;/h3&gt;

&lt;p&gt;Monday's v2 API is GraphQL. Single endpoint, you write the query, you get back exactly the shape you asked for. Creating an item with column values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  mutation CreateItem(
    $boardId: ID!,
    $itemName: String!,
    $columnValues: JSON
  ) {
    create_item(
      board_id: $boardId,
      item_name: $itemName,
      column_values: $columnValues
    ) {
      id
      name
      column_values { id text }
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.monday.com/v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MONDAY_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API-Version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;boardId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1234567890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;itemName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Review Q4 reports&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;columnValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;In Progress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;person&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;personsAndTeams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;person&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-12-15&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get for free with GraphQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Introspection.&lt;/strong&gt; Hit the endpoint with an &lt;code&gt;IntrospectionQuery&lt;/code&gt; and you discover the entire schema — every board type, column type, every relationship. No "guess the field name" guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-request fetches.&lt;/strong&gt; Need an item plus its board plus its column values plus its updates? One query. Asana would be 3-4 sequential REST calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed responses.&lt;/strong&gt; With a code generator (&lt;code&gt;graphql-codegen&lt;/code&gt;), you get TypeScript types matching your queries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's painful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;column_values&lt;/code&gt; requires JSON-in-JSON-string.&lt;/strong&gt; That &lt;code&gt;JSON.stringify&lt;/code&gt; inside &lt;code&gt;JSON.stringify&lt;/code&gt; is a real quirk — Monday wants each column's config as a stringified JSON inside the main GraphQL payload. Easy to forget the inner stringification and silently send broken data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutations don't always echo back the latest state.&lt;/strong&gt; You'll often need a follow-up &lt;code&gt;query&lt;/code&gt; to confirm the write took.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit: 5,000,000 complexity points per minute&lt;/strong&gt; sounds generous, but each query has a "complexity cost" that scales with depth. A nested &lt;code&gt;boards { items { column_values } }&lt;/code&gt; query on 50 items can chew through 10,000+ points per call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Asana: well-organised REST, but it's REST
&lt;/h3&gt;

&lt;p&gt;Asana's API is REST, follows reasonable conventions, returns predictable JSON. Same task creation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.asana.com/api/1.0/tasks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASANA_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1234567890&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Review Q4 reports&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;due_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-12-15&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;assignee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;custom_fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9876543210&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// field ID + enum value, pre-known&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleaner-looking on first glance. The catches show up over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom fields require pre-fetched IDs.&lt;/strong&gt; You hit &lt;code&gt;/projects/{id}/custom_field_settings&lt;/code&gt; once to discover field IDs, then cache them, then reference by ID. There's no equivalent to GraphQL introspection — the schema is documented but not queryable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-fetching.&lt;/strong&gt; REST gives you whole objects. Need a task's name plus its assignee? You get the whole task object plus a partial assignee object. Three separate calls if you want the assignee's full profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination via offset cursor.&lt;/strong&gt; Standard, max 100 per page. Listing 5,000 tasks is 50 sequential calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit: 1,500 reads/minute, 150 writes/minute&lt;/strong&gt; per token. Reasonable for normal apps; you'll throttle on bulk operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Which API wins for your case
&lt;/h3&gt;

&lt;p&gt;If you're building heavy custom integrations — dashboards that aggregate across projects, sync engines that mirror PM data into your own DB, multi-source reports — &lt;strong&gt;Monday's GraphQL is genuinely nicer to work with.&lt;/strong&gt; The introspection alone saves hours, and the single-call multi-resource fetch saves rate-limit budget.&lt;/p&gt;

&lt;p&gt;If you're building simple "create a task when X happens" automations, &lt;strong&gt;Asana's REST is fine and the docs are slightly more beginner-friendly.&lt;/strong&gt; GraphQL has a learning curve; REST is universal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks: both work, both have quirks
&lt;/h2&gt;

&lt;p&gt;Both platforms support outbound webhooks. Both sign payloads. Both require a handshake on subscription (Asana sends an &lt;code&gt;X-Hook-Secret&lt;/code&gt; you echo back; Monday sends a &lt;code&gt;challenge&lt;/code&gt; you respond to).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Asana webhook handler with signature verification&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/asana-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Handshake on first call&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handshakeSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-hook-secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handshakeSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Hook-Secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handshakeSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Normal event — verify signature&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-hook-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASANA_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// event.resource.gid is the task/project ID&lt;/span&gt;
    &lt;span class="c1"&gt;// event.action is 'added', 'changed', 'removed'&lt;/span&gt;
    &lt;span class="nf"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Differences worth knowing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asana groups events.&lt;/strong&gt; One webhook call can contain multiple &lt;code&gt;events&lt;/code&gt; for the same resource. Process all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monday sends one event per call.&lt;/strong&gt; Simpler but more webhook calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neither replays failed webhooks reliably.&lt;/strong&gt; Build idempotency keys into your handler — store recent event IDs and skip duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monday's webhook subscription is via API only&lt;/strong&gt;, not UI. Asana lets you create via UI or API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The custom apps story
&lt;/h2&gt;

&lt;p&gt;If you want to build a UI extension that lives inside the PM tool — not just an external app calling the API — the platforms diverge sharply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monday Apps Framework&lt;/strong&gt; is a real platform. You can build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Board views (custom React components that render inside a board tab)&lt;/li&gt;
&lt;li&gt;Item views (custom panels in the side modal)&lt;/li&gt;
&lt;li&gt;Dashboard widgets&lt;/li&gt;
&lt;li&gt;Integrations (with their integration recipe DSL)&lt;/li&gt;
&lt;li&gt;AI Assistants (Monday's recent push)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apps are written in React, deployed to Monday's marketplace, and can earn revenue. The framework is opinionated but powerful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asana app integrations&lt;/strong&gt; are simpler: OAuth-based external apps that show up in the integration directory. You don't render inside Asana; you call its API and integrate through standard hooks. Easier to build, less native-feeling.&lt;/p&gt;

&lt;p&gt;If your product idea is "a feature that lives inside a PM tool," Monday is the more developer-friendly platform. If it's "a tool that talks to a PM tool," Asana is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost: AI integration math
&lt;/h2&gt;

&lt;p&gt;This is the part the WordPress version covers in detail, but it bleeds into integration design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asana&lt;/strong&gt; bundles AI Studio (50,000 credits/month) with Starter. If you're building a workflow that includes AI summarisation or smart fields, those credits cover your usage without separate billing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monday&lt;/strong&gt; charges per AI credit (~$0.01 each on annual plans). If your integration triggers AI-powered automations frequently, the AI bill scales linearly with usage on top of seats.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For automation-heavy integrations, this matters: a Monday workflow that runs 10,000 AI-powered events per month adds ~$100/month at scale. Asana's bundled credits absorb the same usage at no marginal cost. If your customers will be running AI-heavy workflows through your integration, Asana's pricing model is friendlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-host alternatives, briefly
&lt;/h2&gt;

&lt;p&gt;If you're API-shopping because you're tired of per-seat pricing, two open-source options worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plane&lt;/strong&gt; — JIRA-like, modern stack, REST API. We covered the Docker compose setup in the &lt;a href="https://trackstack.tech/en/clickup-review-2026/" rel="noopener noreferrer"&gt;ClickUp developer review&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vikunja&lt;/strong&gt; — kanban/list focused, simpler setup. Docker compose in our &lt;a href="https://trackstack.tech/en/best-project-management-tools-remote-teams-2026/" rel="noopener noreferrer"&gt;PM tools for remote dev teams post&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trade-off: neither has GraphQL. Both have REST APIs that are competent but not exceptional. The win is you own your data and don't pay per seat.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR for developers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monday wins on API quality.&lt;/strong&gt; GraphQL, introspection, single-request fetches, real apps framework. The 5M complexity-points-per-minute rate limit is generous if you query carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asana wins on simplicity and beginner DX.&lt;/strong&gt; REST, clean docs, fewer pricing-model variables (no AI per-credit math).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both have decent webhooks.&lt;/strong&gt; Build idempotent handlers regardless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden cost driver:&lt;/strong&gt; Monday's AI per-credit pricing adds variable cost to AI-heavy integrations. Asana's bundled AI absorbs the same load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For building UI extensions:&lt;/strong&gt; Monday's apps framework is the more developer-first platform. Asana's app ecosystem is external-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you have 1-2 paid users&lt;/strong&gt;, Asana wins on cost regardless (Monday's 3-seat minimum hurts here). If you have 5+, the choice is more about API philosophy than dollars.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the SMB-perspective comparison (without the API lens — seat-minimum math, AI credit pricing, visual customization vs task management), see the &lt;a href="https://trackstack.tech/en/asana-vs-monday-smb-2026/" rel="noopener noreferrer"&gt;WordPress version of this article&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/asana-vs-monday-smb-2026/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the full SMB pricing breakdown and FAQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>graphql</category>
      <category>productivity</category>
    </item>
    <item>
      <title>PM Tools for Remote Dev Teams 2026: APIs, GitHub Integration, and Self-Host</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Mon, 01 Jun 2026 06:12:08 +0000</pubDate>
      <link>https://dev.to/trackstack/pm-tools-for-remote-dev-teams-2026-apis-github-integration-and-self-host-491m</link>
      <guid>https://dev.to/trackstack/pm-tools-for-remote-dev-teams-2026-apis-github-integration-and-self-host-491m</guid>
      <description>&lt;p&gt;I've run remote engineering teams on four different PM tools across the last five years — Jira, Linear, ClickUp, and GitHub Projects. This is the opinionated take I'd give a CTO friend asking "which one in 2026?" The WordPress version of this piece covers all 10 tools for general SMB; this is the dev-team subset and the API math that actually matters when you're going to script around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dev-team criteria that override "feature count"
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard speed.&lt;/strong&gt; Devs leave PM tools that take 3 seconds to load a board. Linear, GitHub, and Plane fit. ClickUp, Notion, Jira don't, even after their 2024-2025 speed improvements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API quality.&lt;/strong&gt; If you can't script standups, auto-create issues from CI failures, and sync tickets to a Slack bot, the tool will be abandoned in 6 months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub/GitLab integration.&lt;/strong&gt; Real bidirectional — branch names linked to issues, PR status visible on the issue card, merge → close the ticket. Not "we have a Zapier zap."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markdown everywhere.&lt;/strong&gt; Tickets, comments, descriptions. If the tool has its own document format (looking at you, Atlassian Document Format), the friction compounds daily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-host option.&lt;/strong&gt; Not required, but it's a tiebreaker for teams paranoid about data lock-in or just budget-conscious at scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the rubric. Now the contenders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linear — the dev favourite, and it earns it
&lt;/h2&gt;

&lt;p&gt;$10/user/month. The fastest tool in the category — sub-100ms navigation, every action keyboard-accessible, beautifully designed. The GraphQL API is first-class with a typed SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LinearClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@linear/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LinearClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINEAR_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Create an issue from a CI failure&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportCIFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;team&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createIssue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`CI failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Run: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="se"&gt;\`\`\`&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="se"&gt;\`\`\`&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// High&lt;/span&gt;
    &lt;span class="na"&gt;labelIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;issueLabels&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ci&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;15 lines and you have CI-to-Linear in any language with a GraphQL client. The SDK is well-typed; the docs are good; the rate limits are generous (1,500 req/hour authenticated).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where Linear loses:&lt;/strong&gt; it's engineering-only by design. Marketing and ops will feel cramped, and you can't bolt them on. If your dev team works closely with non-dev colleagues, Linear's purity becomes a wall. Also: $10/user × 50 people × 12 months = $6,000/year. Not bad, but not free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jira — only if you're forced into it
&lt;/h2&gt;

&lt;p&gt;$7.75/user/month (Standard). The enterprise software default. It does sprints, backlogs, reporting, and compliance dashboards at a depth Linear can't match. The pain is everything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same "create issue from CI failure" in Jira REST&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportCIFailureJira&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JIRA_HOST&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/rest/api/3/issue`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Basic &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`CI failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ADF, not markdown. This is one paragraph.&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;doc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paragraph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Run: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;codeBlock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
              &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;issuetype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Task&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;High&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atlassian Document Format (ADF) is the API equivalent of a tax return. Every formatting decision becomes a tree of typed nodes. Markdown converters exist but they're approximate. Add OAuth refresh logic, multi-step issue type configuration, custom fields with cryptic IDs, and a rate limit you have to look up per endpoint, and integration time triples vs Linear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Jira when:&lt;/strong&gt; you're on a team where it was picked for you, or you genuinely need its compliance/audit features. Otherwise, the speed and DX gap with Linear is unbridgeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Projects — the underrated pick
&lt;/h2&gt;

&lt;p&gt;Free with any GitHub account. GitHub Projects v2 (the new one, GraphQL-based) is a real PM tool — tables, boards, roadmap, custom fields, automations — built into the place your code already lives. The mutation to add an issue to a project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AddIssueToProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$contentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;addProjectV2ItemById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;contentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$contentId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The integration is unbeatable because there's no integration — your PRs, issues, commits, and project board are one system. Closing an issue via PR is one line in the commit message; sprint velocity is calculated from real merge timestamps; the project board updates as you push code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it loses:&lt;/strong&gt; docs are sparse (use the GraphQL Explorer to discover the schema), the UI is less mature than Linear or ClickUp, and it has no built-in standup or time-tracking. For pure engineering work with a small team (≤15), it's surprisingly enough. For larger teams or anything beyond engineering, you'll outgrow it.&lt;/p&gt;

&lt;h2&gt;
  
  
  ClickUp — the cheap all-in-one for mixed teams
&lt;/h2&gt;

&lt;p&gt;$7/user/month (Unlimited), +$9/user for AI. Worth considering when your team is not engineering-only — when one platform has to serve devs, designers, marketing, and ops. The REST API is decent; webhooks have quirks (we covered them in the &lt;a href="https://trackstack.tech/en/clickup-review-2026/" rel="noopener noreferrer"&gt;ClickUp developer review&lt;/a&gt;). For a dev-only team, Linear wins. For a 30-person remote SMB where dev is 8 people and the rest are ops/marketing, ClickUp at $7 beats running Linear for engineering plus something else for everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notion — the docs-plus-tasks hybrid
&lt;/h2&gt;

&lt;p&gt;$10/user/month. Notion isn't a PM tool, it's a documents tool that grew a database feature. For dev teams that live in design docs, RFCs, ADRs, and runbooks, Notion's "context next to task" beats a dedicated PM tool. The API is RESTful, the schema-via-property-types approach is workable but verbose, and the rate limits will hit you on bulk operations. Best as the docs layer next to Linear or GitHub, not as the primary task tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosted: when SaaS prices stop making sense
&lt;/h2&gt;

&lt;p&gt;For 50+ user teams, the math shifts. Linear at $500/month becomes uncomfortable; Jira's per-user fees keep growing. Two self-hosted alternatives worth knowing in 2026:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plane&lt;/strong&gt; — modern, JIRA-like, the strongest open-source option. We covered the Docker compose setup in the &lt;a href="https://trackstack.tech/en/clickup-review-2026/" rel="noopener noreferrer"&gt;ClickUp developer post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vikunja&lt;/strong&gt; — simpler, kanban/list-focused, two-service setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml — Vikunja self-hosted&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vikunja&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vikunja&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vikunja-db:/var/lib/postgresql/data&lt;/span&gt;

  &lt;span class="na"&gt;vikunja&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vikunja/vikunja:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3456:3456"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_DATABASE_TYPE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_DATABASE_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_DATABASE_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vikunja&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_DATABASE_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_DATABASE_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vikunja&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_SERVICE_PUBLICURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vikunja.yourdomain.com&lt;/span&gt;
      &lt;span class="na"&gt;VIKUNJA_SERVICE_JWTSECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_SECRET}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vikunja-files:/app/vikunja/files&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vikunja-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vikunja-files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop it on a $6 VPS, point a subdomain, slap Caddy in front for TLS, and you have a no-monthly-fee PM tool with a real REST API, native sharing, and Kanban/list/Gantt views. Trade-offs: no native GitHub integration (you write webhooks yourself), no advanced sprint reporting, and the community is smaller than Plane's. Worth it for small engineering teams who like owning their stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest reality of self-hosting:&lt;/strong&gt; budget 2-4 hours/month for backups, updates, and the occasional firefight. That's ~$50-100/month in your time at consultant rates. The self-host wins when team size × SaaS per-seat cost crosses ~$200/month, not before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes devs make picking PM tools
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Optimising for features instead of adoption.&lt;/strong&gt; The best tool is the one your team will actually update. Linear is loved because devs find updating it pleasant; Jira is hated because every interaction feels like paperwork. If your team won't update the tool, you don't have a PM tool — you have a graveyard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Falling for "all-in-one" pitches.&lt;/strong&gt; ClickUp's "replace 10 tools!" is genuine value for non-dev SMBs. For dev teams, you'll spend 6 months configuring it to feel like Linear, then switch to Linear. Specialised wins for technical workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring the AI add-on math.&lt;/strong&gt; ClickUp Brain is $9/user/mo extra; Notion AI is bundled but capped; Jira AI is enterprise-tier only. If "AI features" are in the pitch, calculate the real cost — your team's existing ChatGPT/Claude Pro subs may already cover 80% of what the in-tool AI does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Underestimating webhook reliability cost.&lt;/strong&gt; Every integration ("PR opened → create issue") needs retry logic, signature verification, and idempotency. Linear's webhooks are clean; Jira's are powerful but quirky; GitHub's are battle-tested; ClickUp's are workspace-scope-only. Build resilient handlers regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engineering-only, 2-50 people, latency matters more than budget:&lt;/strong&gt; Linear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engineering-only, on a shoestring, already using GitHub heavily:&lt;/strong&gt; GitHub Projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forced to use Jira by org/regulation:&lt;/strong&gt; at least lobby for Linear sync via API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed team (dev + non-dev) at SMB:&lt;/strong&gt; ClickUp Unlimited, accept the speed hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs-first team where work emerges from RFCs:&lt;/strong&gt; Notion + GitHub Projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;50+ people, want to own the stack:&lt;/strong&gt; Plane (JIRA-like) or Vikunja (simpler).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the broader 10-tool comparison covering Asana, Monday, Trello, Basecamp, Wrike, and Height (the tools more relevant to non-dev SMBs), see the &lt;a href="https://trackstack.tech/en/best-project-management-tools-remote-teams-2026/" rel="noopener noreferrer"&gt;WordPress version of this piece&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/best-project-management-tools-remote-teams-2026/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the full 10-tool comparison and FAQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>ClickUp from a Developer's Perspective in 2026: API, Webhooks, and the Self-Host Question</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Wed, 27 May 2026 08:49:48 +0000</pubDate>
      <link>https://dev.to/trackstack/clickup-from-a-developers-perspective-in-2026-api-webhooks-and-the-self-host-question-42cn</link>
      <guid>https://dev.to/trackstack/clickup-from-a-developers-perspective-in-2026-api-webhooks-and-the-self-host-question-42cn</guid>
      <description>&lt;p&gt;I built a custom team dashboard on top of ClickUp's API for 6 months in 2025–2026. Three things I wish someone had told me before starting, then a tour of the API, webhooks, the Brain AI gap, and the self-hosted alternative I keep recommending to people who ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-bullet honest take
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClickUp the product&lt;/strong&gt; is genuinely useful if your team needs tasks + docs + dashboards in one place. At $7/user/month on Unlimited, it's cheaper than Linear, Asana, or Monday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClickUp the API&lt;/strong&gt; is OK. REST endpoints, predictable shapes for tasks/lists/spaces, but the docs have gaps and the webhook subsystem is quirky. You'll write more glue code than you'd expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClickUp the AI ("Brain")&lt;/strong&gt; is a $9/user/month add-on with limited API surface. If you want to extend AI workflows programmatically, you're mostly better off calling OpenAI/Anthropic directly with ClickUp as the data source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you came here looking for "ClickUp vs Notion vs Linear" for your team, the WordPress version of this piece has the full SMB comparison. This post is for engineers building on the platform or weighing its API against alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API: better than Jira, worse than Linear
&lt;/h2&gt;

&lt;p&gt;ClickUp's REST API uses a personal token or OAuth, returns JSON, and follows reasonable conventions. Creating a task takes one POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node-fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLICKUP_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLICKUP_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LIST_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;901234567&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// from the URL or /list endpoint&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customFields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.clickup.com/api/v2/list/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LIST_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/task`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CLICKUP_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 1=urgent, 2=high, 3=normal, 4=low&lt;/span&gt;
        &lt;span class="na"&gt;custom_fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customFields&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ClickUp API &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works. Custom fields use a separate sub-resource — you fetch the field IDs from the list endpoint once, store them, and reference them when creating/updating tasks. The schema discovery dance feels old-school but it's not slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's painful:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pagination&lt;/strong&gt; uses &lt;code&gt;page&lt;/code&gt; parameter, max 100 items per page, no cursor. Listing 5,000 tasks is 50 sequential calls with rate limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt; are 100 req/minute per token. Fine for normal apps; you'll throttle on bulk imports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API v2 vs internal API.&lt;/strong&gt; The official v2 covers most operations. Some features (specific automation actions, certain Brain operations, advanced reporting) only exist in the un-documented internal API, which the official docs don't acknowledge. Don't build on it — it changes without notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time tracking endpoints&lt;/strong&gt; are split awkwardly between &lt;code&gt;/team/{id}/time_entries&lt;/code&gt; and &lt;code&gt;/task/{id}/time&lt;/code&gt; and don't always return matching shapes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Webhooks: powerful but quirky
&lt;/h2&gt;

&lt;p&gt;ClickUp webhooks fire on events at the workspace ("team") level. You subscribe via API, not the UI, and you point them at your HTTPS endpoint. A minimal handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// IMPORTANT: raw body for signature verification&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/clickup-webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLICKUP_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;taskCreated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;handleNewTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;taskUpdated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;handleTaskChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;history_items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="c1"&gt;// ...many more event types&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The quirks:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook payloads are &lt;strong&gt;minimal&lt;/strong&gt; — usually just &lt;code&gt;task_id&lt;/code&gt; and &lt;code&gt;history_items&lt;/code&gt;. To get the full task, you make a separate API call. Why this is the design, I don't know. It does halve the average payload size, but it doubles your API calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No payload replay&lt;/strong&gt; in the UI. If your endpoint was down, you find out from logs, not from a "resend failed webhooks" button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workspace ("Team") scope only.&lt;/strong&gt; You can't subscribe to events on a specific space or list — you get all events in the workspace and filter client-side. Noisy if you only care about one project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signature verification is HMAC-SHA256&lt;/strong&gt; of the raw body. Standard but the docs example uses string concatenation that breaks if you've JSON-parsed already. Always use &lt;code&gt;express.raw&lt;/code&gt; middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Brain AI gap
&lt;/h2&gt;

&lt;p&gt;ClickUp Brain is the $9/user/month AI add-on. Inside the UI it's genuinely useful — task summaries, standup generation, AI fields that fill themselves based on rules. From the API perspective, the surface is thin. You can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trigger some AI actions via webhook responses to certain events.&lt;/li&gt;
&lt;li&gt;Read AI-generated content from custom fields that have AI sources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You &lt;strong&gt;cannot&lt;/strong&gt; easily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run arbitrary prompts against your ClickUp data programmatically.&lt;/li&gt;
&lt;li&gt;Use Brain credits from external systems.&lt;/li&gt;
&lt;li&gt;Replace it with your own LLM provider while keeping the in-app AI features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For developers extending ClickUp, the more honest pattern is: skip Brain, build your own AI layer with OpenAI/Anthropic on top of the regular ClickUp API. You pay per-call instead of per-user, you control the prompts, and you don't get billed twice for the same model providers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pattern: pull task context from ClickUp, send to OpenAI, write summary back&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;summariseTasksForStandup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchTasksUpdatedToday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildStandupPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postToClickUpComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;15 lines of glue gets you 80% of what Brain offers, at a fraction of the cost for teams of 10+.&lt;/p&gt;

&lt;h2&gt;
  
  
  When self-hosting beats ClickUp: Plane
&lt;/h2&gt;

&lt;p&gt;If the lock-in math doesn't work for you (a 50-person team on Business + Brain is ~$1,050/month, ~$12,600/year), the strongest self-hosted alternative right now is &lt;a href="https://plane.so" rel="noopener noreferrer"&gt;Plane&lt;/a&gt; — open-source, modern stack, JIRA-like feature surface but cleaner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml — Plane self-hosted, minimal&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plane-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=plane&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${PG_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=plane&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plane-db:/var/lib/postgresql/data&lt;/span&gt;

  &lt;span class="na"&gt;plane-redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plane-redis:/data&lt;/span&gt;

  &lt;span class="na"&gt;plane-web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;makeplane/plane-frontend:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_API_BASE_URL=https://api.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plane-api&lt;/span&gt;

  &lt;span class="na"&gt;plane-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;makeplane/plane-backend:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000:8000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgres://plane:${PG_PASSWORD}@plane-db:5432/plane&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_URL=redis://plane-redis:6379&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY=${PLANE_SECRET}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plane-db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plane-redis&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plane-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plane-redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reality check: Plane doesn't have docs, whiteboards, chat, time tracking, or built-in AI like ClickUp. It does sprints, projects, cycles, pages (basic), and views. For engineering-only teams, that's enough. For agencies juggling clients + content + ops, ClickUp's breadth still wins.&lt;/p&gt;

&lt;p&gt;Other self-hosted alternatives worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vikunja&lt;/strong&gt; — simpler, Trello-like&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Focalboard&lt;/strong&gt; — Mattermost's offering, decent kanban&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenProject&lt;/strong&gt; — older, more enterprise-flavored&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When ClickUp wins for developers specifically
&lt;/h2&gt;

&lt;p&gt;Despite all the above, ClickUp does some things genuinely well for technical teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom fields are a real data model.&lt;/strong&gt; Type-safe-ish (number, dropdown, formula, relationship). You can model real domain data on top of tasks, not just "todo with notes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The API supports almost everything the UI does.&lt;/strong&gt; Unlike Notion, where database property edits are easier in UI than API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth flow works.&lt;/strong&gt; You can build apps that other teams install. The Marketplace is small but the mechanism is there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded views in iframes.&lt;/strong&gt; You can drop a ClickUp dashboard into your internal tool with one URL. Niche but useful.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building internal tooling on top of a project management platform, ClickUp is a defensible choice — not the obvious winner, but workable. Linear has a cleaner GraphQL API; Notion has cleaner database semantics; ClickUp has more features per dollar and accepts more domain modelling than either.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClickUp API:&lt;/strong&gt; REST, works, pagination is annoying, rate-limited at 100/min, watch out for un-documented internal endpoints that aren't stable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks:&lt;/strong&gt; Workspace-scope only, minimal payloads, no UI replay. Build resilient handlers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brain AI:&lt;/strong&gt; Limited API surface — better to skip and use OpenAI directly on top of ClickUp data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-host alternative:&lt;/strong&gt; Plane for engineering teams; ClickUp still wins if you need breadth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worth it for devs?&lt;/strong&gt; As an internal-tooling platform — yes, if the team is already on ClickUp. As a greenfield choice — Linear or a self-hosted option will give you fewer headaches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the broader SMB-perspective comparison (pricing in detail, the hidden Brain cost, alternatives like Asana and Monday), the &lt;a href="https://trackstack.tech/en/clickup-review-2026/" rel="noopener noreferrer"&gt;WordPress version of this article&lt;/a&gt; has the full breakdown.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/clickup-review-2026/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the SMB pricing analysis and FAQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>api</category>
      <category>webdev</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Notion vs Obsidian for Developers: APIs, Plugins, and Why I Use Both</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Tue, 26 May 2026 05:33:48 +0000</pubDate>
      <link>https://dev.to/trackstack/notion-vs-obsidian-for-developers-apis-plugins-and-why-i-use-both-16d0</link>
      <guid>https://dev.to/trackstack/notion-vs-obsidian-for-developers-apis-plugins-and-why-i-use-both-16d0</guid>
      <description>&lt;p&gt;I keep my project tracker in Notion and my actual thinking in Obsidian. After three years of moving between the two, that split is the only sane answer I have to "which one?" — and it's mostly driven by what each tool's API and file format make easy.&lt;/p&gt;

&lt;p&gt;This isn't another "Notion is great, Obsidian is great, depends on you!" post. It's the developer cut: what you can actually build, what locks you in, and where each tool quietly fails when you push it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock-in test, before anything else
&lt;/h2&gt;

&lt;p&gt;Run this thought experiment for any note-taking tool: &lt;strong&gt;if the company shuts down tomorrow at midnight, what's your recovery story?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Notion:&lt;/strong&gt; You get a Markdown export. Databases collapse into flat folders, inline blocks lose fidelity, internal links break. Rebuilding a complex workspace elsewhere is a weekend job at best, "lost forever" at worst. The export technically works; the workflow that depended on it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obsidian:&lt;/strong&gt; You already have a folder of &lt;code&gt;.md&lt;/code&gt; files on your disk. Open it in VS Code, Logseq, Foam, or any markdown editor. You lose plugins; you don't lose data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If portability matters to you (and as a developer, it should), Obsidian wins this round without firing a shot. Whether that's enough to outweigh everything else is the rest of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notion's API: programmable, well-documented, rate-limited
&lt;/h2&gt;

&lt;p&gt;Notion's REST API is genuinely good. It's the rare SaaS API where the docs match the behaviour. Adding a page to a database is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notionhq/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;database_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_DB_ID&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New idea&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;Tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;multi_select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;research&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means you can build real automations: cron jobs that scrape an RSS feed into a Notion reading list, Slack bots that capture quotes, scripts that batch-import old notes. The API rate-limits at ~3 req/sec average per integration — fine for personal use, painful for bulk operations.&lt;/p&gt;

&lt;p&gt;What you can't easily do via the API: full-text search across pages (limited), edit specific blocks deep inside a page (clunky), or react to page changes (webhooks exist but only for selected events). For "push data in" the API is great; for "pull data out and process" it's workable; for "react to edits" you're polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Obsidian's plugin API: TypeScript, local-first, no rate limits
&lt;/h2&gt;

&lt;p&gt;Obsidian isn't a SaaS — it's a local app — so there's no REST API. Instead, you write &lt;strong&gt;plugins in TypeScript&lt;/strong&gt; that run inside the app. Minimal plugin that adds a command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Notice&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;obsidian&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StampPlugin&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Plugin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;insert-timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Insert ISO timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;editorCallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceSelection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addRibbonIcon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Stamp the page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Notice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Vault path: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin API exposes everything: read/write files in the vault, register commands, hook into edit events, build settings UIs, render custom views, traverse the link graph. The community has 1,500+ published plugins because the API is approachable.&lt;/p&gt;

&lt;p&gt;The flip side: there's no way to interact with Obsidian from outside the app. If you want a cron job to add notes to your vault, you write directly to the markdown files on disk (or use a sync provider as the intermediary). That's not a bug — it's the philosophy. Obsidian doesn't own your data, so it doesn't need an API to give it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each one wins for developers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Notion wins on structured data.&lt;/strong&gt; Databases with typed properties, filtered views, and rollups give you something close to a personal Airtable. If your "knowledge" includes things like a reading list with status/rating, a content calendar, a CRM-ish contact log, or a project tracker with kanban — Notion handles these in 5 minutes. Trying to recreate the same in Obsidian needs the Dataview plugin and custom query syntax.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Obsidian wins on text.&lt;/strong&gt; Plain markdown means every other tool in your stack already speaks the format — VS Code, Git, GitHub, pandoc, your shell. Want to grep across 10,000 notes?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find every note that mentions "kubernetes" in your vault&lt;/span&gt;
rg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes"&lt;/span&gt; ~/vault &lt;span class="nt"&gt;--type&lt;/span&gt; md

&lt;span class="c"&gt;# All notes modified in the last 7 days&lt;/span&gt;
find ~/vault &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.md"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; &lt;span class="nt"&gt;-7&lt;/span&gt;

&lt;span class="c"&gt;# Word count across the entire vault&lt;/span&gt;
find ~/vault &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.md"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; + | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can't do any of this against Notion without using their API and burning rate-limit budget. For developers who already live in the terminal, this is genuinely transformative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split that actually works
&lt;/h2&gt;

&lt;p&gt;After years of trying to pick one, here's what I run in 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Notion for structured projects.&lt;/strong&gt; Content calendar, client tracker, weekly review, OKRs. Anything that benefits from "show me the kanban / show me the calendar / show me the filtered list of overdue items."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obsidian for thinking.&lt;/strong&gt; Daily notes, research, code-related notes, journal, longform drafts, anything that grows by linking to other things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resist the urge to sync them.&lt;/strong&gt; I tried for a year. Every sync solution adds more failure modes than it solves. The two tools work better as separate brains for separate jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The total cost is ~$50/year for Obsidian Sync (Notion Personal Free is plenty for my project tracking). Cheaper than any all-in-one solution that does both jobs worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration script that's actually useful
&lt;/h2&gt;

&lt;p&gt;If you're considering moving from Notion → Obsidian, the export gives you a Markdown zip — but it's gnarly. Page IDs in filenames, broken internal links, embedded databases as inline tables. A small Python pass cleans most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# notion-to-obsidian.py — quick cleanup pass on Notion's markdown export
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;EXPORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./notion-export&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./obsidian-vault&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Notion filenames: "Page Title abc123def456789012345678901234.md"
&lt;/span&gt;&lt;span class="n"&gt;NOTION_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+[a-f0-9]{32}(\.md)?$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Notion internal links: "[Title](Title%20abc123...md)"
&lt;/span&gt;&lt;span class="n"&gt;LINK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\[([^\]]+)\]\([^)]+%20[a-f0-9]{32}[^)]*\)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;md&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;EXPORT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Clean filename
&lt;/span&gt;    &lt;span class="n"&gt;clean_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTION_ID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;md&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Read, rewrite internal links to wiki-style
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LINK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[[\1]]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Drop Notion's auto-generated frontmatter blocks if any
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^Created:.*\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MULTILINE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^Last Edited:.*\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MULTILINE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OUT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;clean_name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Migrated &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; files to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;OUT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a perfect migration — databases still become flat folders, image references need separate handling, callouts and toggles lose their special rendering. But it gets you to "openable in Obsidian without screaming" in one pass, which is the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas, both directions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Notion's API counts every call.&lt;/strong&gt; If you're polling for changes every minute, that's 43k calls/month, well within personal limits but burns through integration quotas if you build on top. Use the &lt;code&gt;last_edited_time&lt;/code&gt; filter to fetch only changed pages instead of full database queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Obsidian's plugin API can break across versions.&lt;/strong&gt; Major Obsidian updates occasionally break plugins; if you depend on a plugin for core workflow, pin the plugin version and test before updating Obsidian.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't store secrets in Notion pages.&lt;/strong&gt; Their search is full-text and accessible to anyone with workspace access — including past members whose invites you forgot to revoke. For API keys and secrets, use a real password manager, not your note app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Obsidian Sync is end-to-end encrypted; Notion is not.&lt;/strong&gt; Notion can read your data; Obsidian (with Sync) cannot. For sensitive personal journals, this matters. Self-host the markdown files via Git or Syncthing if you don't want a third party in the loop at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both have AI features that send your notes to LLM providers.&lt;/strong&gt; Notion AI sends to their backend (which calls OpenAI/Anthropic). Obsidian AI plugins use your API keys, so the API provider sees the data. Neither is "private by default" when AI is involved — read the docs before enabling.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Notion&lt;/strong&gt; has a great REST API. Easy to script "push data in" workflows. Locks your data in a database that exports poorly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obsidian&lt;/strong&gt; has a great plugin API and gives you plain markdown files on disk. No external API, but you don't need one — &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;find&lt;/code&gt;, and Git already work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For developers specifically:&lt;/strong&gt; Obsidian for thinking, Notion for tracking structured stuff. Use both, accept the split, stop trying to find one tool to rule them all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you write code for a living, the migration cost of getting trapped in someone's proprietary format is real. Bias toward plain text where the philosophy of your notes allows it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/notion-vs-obsidian-personal-knowledge-2026/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the broader non-developer comparison and FAQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>notionchallenge</category>
      <category>obsidian</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Email Marketing Automation in 2026: 5 Tools (and 1 Self-Hosted) Through Their APIs</title>
      <dc:creator>TrackStack</dc:creator>
      <pubDate>Mon, 25 May 2026 06:02:21 +0000</pubDate>
      <link>https://dev.to/trackstack/email-marketing-automation-in-2026-5-tools-and-1-self-hosted-through-their-apis-5fo3</link>
      <guid>https://dev.to/trackstack/email-marketing-automation-in-2026-5-tools-and-1-self-hosted-through-their-apis-5fo3</guid>
      <description>&lt;p&gt;I've wired up email automation in 5 different SaaS products over the last 3 years. Every time the team asks "Mailchimp or…?" — and every time the right answer depends on whether you actually need marketing campaigns, transactional sends, or both. This post is the version of that conversation aimed at people who'd rather see a &lt;code&gt;curl&lt;/code&gt; than a pricing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision tree
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What are you sending?
├── Transactional only (receipts, password resets, magic links)
│   → Resend / Postmark / SES (skip the rest of this post)
│
├── Marketing only (newsletters, campaigns, drips)
│   → Brevo (free, has API), MailerLite ($10/mo), 
│     or Listmonk (self-hosted)
│
└── Both, from the same tool
    → Brevo (unique combo at this price)
    │ or
    → Klaviyo (if e-commerce)
    │ or
    → ActiveCampaign Plus + transactional add-on ($$$)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "both from the same tool" path is where most teams over-engineer. Splitting transactional (Resend) and marketing (anything) is usually cheaper and gives better deliverability — but harder to manage one source of truth on the contact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The platforms, briefly
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Brevo — best free, decent API, dual-purpose
&lt;/h3&gt;

&lt;p&gt;300 emails/day on free, unlimited contacts, basic automation. Bills by emails sent, not contacts — a huge win if you have a big inactive list. Their REST API supports both transactional sends and marketing list management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Add contact to a list AND send transactional welcome — one Node script&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node-fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BREVO_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BREVO_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onboardUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Add to marketing list&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.brevo.com/v3/contacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BREVO_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;FIRSTNAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;listIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// "New users" list&lt;/span&gt;
      &lt;span class="na"&gt;updateEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// upsert&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Fire transactional welcome&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.brevo.com/v3/smtp/email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BREVO_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// your template&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two API calls, both authenticated by the same key. Most platforms force you to pay for separate transactional infrastructure (Mailgun/Postmark) plus marketing — Brevo bundles them.&lt;/p&gt;

&lt;h3&gt;
  
  
  MailerLite — clean UI, decent API, smaller free
&lt;/h3&gt;

&lt;p&gt;500 subscribers on free (cut from 1,000 in September 2025), 12k emails/month, automation builder included. API is straightforward but transactional is a separate paid product (MailerSend). For just marketing, the API works well; the value prop is the editor more than the API.&lt;/p&gt;

&lt;h3&gt;
  
  
  ActiveCampaign — best automation, API is verbose
&lt;/h3&gt;

&lt;p&gt;No free plan, $15/month entry for 1,000 contacts on Starter. The automation builder is the industry benchmark. The API works but it's REST in a 2010 sense — endpoints for everything, lots of object types (contact, deal, tag, list, automation, custom field, etc.). You'll write more code per integration than you would on Brevo or Klaviyo. Use it only when your team has bought into ActiveCampaign for the automation power, not for API DX.&lt;/p&gt;

&lt;h3&gt;
  
  
  Klaviyo — event-driven, the best dev API
&lt;/h3&gt;

&lt;p&gt;Klaviyo's free tier is small (250 contacts, 500 emails/month), but their API is the cleanest of the bunch and built around &lt;strong&gt;events&lt;/strong&gt;, not lists. You don't "add a contact to a campaign" — you push events, and segmentation rules on Klaviyo's side decide who gets what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Track an event — Klaviyo handles segmentation + automation triggers&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node-fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://a.klaviyo.com/api/events/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Klaviyo-API-Key &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KLAVIYO_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2024-10-15&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;OrderID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ORD-12345&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;89.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sku-001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sku-007&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Placed Order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single event triggers any automation Klaviyo has set up for "Placed Order" — receipt, cross-sell sequence, review request 7 days later, whatever. This is why Klaviyo dominates e-commerce: shops want event-driven, not list-driven thinking, and the API matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  HubSpot Marketing Hub — only if HubSpot is your CRM
&lt;/h3&gt;

&lt;p&gt;2k emails/month on free, $20/month Starter, then a $890/month cliff to Professional. The API is solid (HubSpot has invested heavily) but you're paying for the whole CRM bundle. Don't pick HubSpot Marketing standalone — pick HubSpot if you already use their CRM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listmonk — the self-hosted answer
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://listmonk.app" rel="noopener noreferrer"&gt;Listmonk&lt;/a&gt; is open-source, Go-based, single-binary or Docker. No task counter, no contact limit, no premium feature gates. Drop this on a $6 VPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=listmonk&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=listmonk&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=listmonk&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;listmonk-data:/var/lib/postgresql/data&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;listmonk/listmonk:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9000:9000"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Kyiv&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config.toml:/listmonk/config.toml&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;listmonk-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.toml&lt;/code&gt; points at the Postgres above plus an SMTP provider (Amazon SES, Mailgun, or even Gmail for testing). The API is JSON, well-documented, and supports lists, subscribers, campaigns, and transactional templates.&lt;/p&gt;

&lt;p&gt;What you don't get out of the box: visual automation builder (you wire campaigns to send on a schedule or trigger them via the API yourself), abandoned-cart logic, behavioral segmentation as deep as Klaviyo. What you get: complete control, $5/month total cost, no vendor lock-in. For SaaS founders running small lists who'd rather write code than fight a SaaS UI, this is the play.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transactional vs marketing — the split most teams should make
&lt;/h2&gt;

&lt;p&gt;A common mistake: picking one tool to do both because "it's simpler." Reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transactional&lt;/strong&gt; (receipts, password resets, magic links): low volume per user but mission-critical, must arrive in &amp;lt;30s, can't go to spam. Postmark, Resend, SES, Mailgun are purpose-built. Sending IP reputation is managed for transactional separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing&lt;/strong&gt; (campaigns, drips, newsletters): bulk send, latency doesn't matter, deliverability is shared-IP territory until you scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixing them on the same shared IP means a bad marketing campaign (spam complaints) can throttle your password-reset deliverability. Brevo and ActiveCampaign run separate infrastructure for the two even though they're one product. Klaviyo doesn't do transactional at all.&lt;/p&gt;

&lt;p&gt;The split most teams should make: &lt;strong&gt;Resend or Postmark for transactional ($0–10/month at SaaS scale), Brevo or Listmonk for marketing.&lt;/strong&gt; Two integrations, both small.&lt;/p&gt;

&lt;h2&gt;
  
  
  API quality, ranked
&lt;/h2&gt;

&lt;p&gt;Based on my actual integration experience, not vendor copy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Klaviyo&lt;/strong&gt; — clean REST, event-first, excellent docs, sane errors, generous rate limits (75 req/sec)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brevo&lt;/strong&gt; — clean REST, both transactional and marketing under one key, docs OK&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HubSpot&lt;/strong&gt; — surprisingly good, OAuth flow is well-supported, schema is large but consistent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ActiveCampaign&lt;/strong&gt; — works, but verbose; many endpoints, inconsistent response shapes between v1 and v3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mailchimp&lt;/strong&gt; — works, but the segmentation API is genuinely painful (string-based filter syntax that's easy to get wrong)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listmonk&lt;/strong&gt; — clean and simple, but smaller surface area than the SaaS APIs; some features exist only in the UI&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Double opt-in vs API adds.&lt;/strong&gt; Most platforms have a "skip double opt-in when added via API" setting, but the default usually requires confirmation. If your onboarding flow assumes the contact is immediately on the list, configure this explicitly or your welcome sequence won't fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency on contact creation.&lt;/strong&gt; Re-running an integration test can create duplicate contacts. Brevo's &lt;code&gt;updateEnabled: true&lt;/code&gt;, Klaviyo's profile upsert by email, and Mailchimp's MD5-hashed-email subscriber ID all solve this — but you have to use them. Default behaviour is often "error on duplicate," not "merge."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits.&lt;/strong&gt; Klaviyo gives you 75 req/sec; Brevo 400 req/min; ActiveCampaign 5 req/sec. Bulk imports need throttling. The Mailchimp batch endpoint exists for a reason — use it instead of looping single calls when adding more than a few hundred contacts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook signature verification.&lt;/strong&gt; All these platforms support outbound webhooks (e.g., "campaign sent," "contact unsubscribed"). All of them sign payloads. Verify before processing — it's not optional. The pattern's the same as Stripe or HubSpot HMAC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom fields and types.&lt;/strong&gt; Spreadsheets and JSON have loose types; CRMs are strict. A blank &lt;code&gt;""&lt;/code&gt; is not &lt;code&gt;null&lt;/code&gt;, a "yes" is not a &lt;code&gt;true&lt;/code&gt;, a "$1,200" isn't &lt;code&gt;1200&lt;/code&gt;. Normalize before API call. This burns more integration time than auth setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marketing only, free + good API:&lt;/strong&gt; Brevo. 300/day, unlimited contacts, REST that doesn't make you cry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing + transactional, one bill:&lt;/strong&gt; Brevo again. Rare combo at this price point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E-commerce, event-driven:&lt;/strong&gt; Klaviyo. The best API of the bunch, period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already on HubSpot CRM:&lt;/strong&gt; HubSpot Marketing Hub Starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hate vendors, love Docker:&lt;/strong&gt; Listmonk on a $6 VPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid:&lt;/strong&gt; Mailchimp (API is dated) unless your team is already locked in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For SaaS founders specifically, the split I'd recommend in 2026 is &lt;strong&gt;Resend (transactional) + Brevo (marketing) + a webhook into your app&lt;/strong&gt;, total ~$10–20/month at small scale. You'll spend less time fighting tooling and more time on the actual product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://trackstack.tech/en/email-marketing-automation-tools-smb-2026/" rel="noopener noreferrer"&gt;trackstack.tech&lt;/a&gt; with the SMB-perspective comparison table and FAQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
