<?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: Hao Xu</title>
    <description>The latest articles on DEV Community by Hao Xu (@hao_xu_ddc9d6fcd6b350a703).</description>
    <link>https://dev.to/hao_xu_ddc9d6fcd6b350a703</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%2F4157954%2F61b3034a-5173-4e16-941e-a4a61ed621f2.png</url>
      <title>DEV Community: Hao Xu</title>
      <link>https://dev.to/hao_xu_ddc9d6fcd6b350a703</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hao_xu_ddc9d6fcd6b350a703"/>
    <language>en</language>
    <item>
      <title>What I learned building my first SaaS: invoice tracking, Stripe Connect, private payment links and lean analytics</title>
      <dc:creator>Hao Xu</dc:creator>
      <pubDate>Fri, 02 Oct 2026 15:57:59 +0000</pubDate>
      <link>https://dev.to/hao_xu_ddc9d6fcd6b350a703/what-i-learned-building-my-first-saas-invoice-tracking-stripe-connect-private-payment-links-and-5524</link>
      <guid>https://dev.to/hao_xu_ddc9d6fcd6b350a703/what-i-learned-building-my-first-saas-invoice-tracking-stripe-connect-private-payment-links-and-5524</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://invosmith.com" rel="noopener noreferrer"&gt;Invosmith&lt;/a&gt;, an invoicing tool for freelancers and small service businesses, on Next.js + Cloudflare Workers + D1. It's my first real product, and most of the interesting problems weren't the ones I expected. Here are six of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Was my invoice opened?" — tracking without lying to the user
&lt;/h2&gt;

&lt;p&gt;The most-requested thing from freelancers is simple: &lt;em&gt;did the client even look at it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The obvious answer is an email tracking pixel. I didn't rely on it. Apple Mail Privacy Protection and some corporate mail scanners fetch images automatically, so a pixel can report "opened" when nobody looked. That's worse than no tracking: the user stops chasing a client who never saw the invoice.&lt;/p&gt;

&lt;p&gt;Instead, "Viewed" means the client opened the &lt;strong&gt;hosted invoice page&lt;/strong&gt; from the email link. It's a narrower claim but an honest one, and the UI says exactly that: &lt;em&gt;viewed on the invoice page&lt;/em&gt;, not &lt;em&gt;read your email&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Lesson: an event that is sometimes wrong in a direction that costs the user money (they stop following up) is worse than a smaller, true signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Stripe Connect: the money must never touch my account
&lt;/h2&gt;

&lt;p&gt;Clients pay invoices by card. The tempting design is to collect the money and pay the freelancer out. Don't: holding other people's money pulls you toward money-transmitter licensing.&lt;/p&gt;

&lt;p&gt;Setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard connected accounts&lt;/strong&gt;, Stripe-hosted onboarding. Stripe handles KYC and risk for each freelancer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct charges&lt;/strong&gt;: the payment is created on the freelancer's connected account, so funds land in &lt;em&gt;their&lt;/em&gt; Stripe balance. My own account only ever sees my subscription revenue.&lt;/li&gt;
&lt;li&gt;My acceptance test is literal: if an invoice payment ever shows up in my own balance, the architecture is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two webhook lessons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep &lt;strong&gt;two separate endpoints&lt;/strong&gt;: one for my own billing (&lt;code&gt;checkout.session.completed&lt;/code&gt;, subscription events) and one scoped to &lt;strong&gt;connected accounts&lt;/strong&gt; (&lt;code&gt;account.updated&lt;/code&gt;, &lt;code&gt;charge.refunded&lt;/code&gt;, disputes). Mixing them makes signature secrets and event routing confusing fast.&lt;/li&gt;
&lt;li&gt;In the Stripe dashboard's event picker, grouped checkboxes can silently select sibling events. I ended up subscribed to legacy card/source events that never fire on a PaymentMethods integration. Pick events one by one and watch the count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqprkjux21gwh7fpo4zf7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqprkjux21gwh7fpo4zf7.png" alt=" " width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Payment links that are private but still work for the client
&lt;/h2&gt;

&lt;p&gt;The client has no account, so the invoice link itself is the credential. That shapes everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate subdomain.&lt;/strong&gt; Marketing lives on the main domain, the logged-in app on &lt;code&gt;app.&lt;/code&gt;, and client-facing invoices on &lt;code&gt;pay.&lt;/code&gt;. The reason is indexing: I want the marketing site (and eventually the app login) in Google, and I never want invoices there. I saw a well-known invoicing app put share links on the same host as its indexed login page, and some of those links ended up in search results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pay.&lt;/code&gt; is permanently &lt;code&gt;noindex&lt;/code&gt; + blocked in robots.txt.&lt;/strong&gt; But noindex is the floor, not the security model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unguessable tokens.&lt;/strong&gt; The link carries a long random token from a cryptographically secure generator, not an incrementing ID. Sequential IDs let anyone walk through other people's invoices (an IDOR bug).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every server read is scoped&lt;/strong&gt;, on the owner side by account and on the client side by token, never by an ID from the URL alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No caching of private responses&lt;/strong&gt; (&lt;code&gt;no-store&lt;/code&gt;), and session cookies never travel to the marketing site where third-party scripts run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flyly7opvb7iigc6ug5a2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flyly7opvb7iigc6ug5a2.png" alt=" " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Analytics without exploding row counts
&lt;/h2&gt;

&lt;p&gt;I wanted to know which pages and features actually drive signups. My first instinct was to log everything as rich events with lots of properties. On D1, where you pay attention to row counts and storage, that doesn't scale for a tiny product.&lt;/p&gt;

&lt;p&gt;What worked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analytics lives in a &lt;strong&gt;separate D1 database&lt;/strong&gt; from the app, so a noisy events table can never slow down or bloat the database that holds invoices.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;small, fixed event vocabulary&lt;/strong&gt; registered in code; unregistered events are rejected, not stored.&lt;/li&gt;
&lt;li&gt;Properties are constrained to &lt;strong&gt;five parameter categories&lt;/strong&gt;: [TODO: list your 5, e.g. page type / funnel step / feature / plan / source]. Everything else is dropped at the edge. That keeps the number of distinct combinations bounded, so "one row per event" stays small and every query can group by a known column.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Using the Search Console API to decide what to build next
&lt;/h2&gt;

&lt;p&gt;The Search Console UI caps exports at 1,000 rows, so I pulled data through the API with a service account: impressions, clicks and position by &lt;strong&gt;query × page&lt;/strong&gt;, daily.&lt;/p&gt;

&lt;p&gt;What it told me: the head term ("invoice generator") is held by incumbents with a keyword difficulty around 65. A new domain won't rank for it in any reasonable time. But I was getting impressions on long-tail, vertical queries (contractor invoices, progress billing, specific document types) where difficulty is in single digits.&lt;/p&gt;

&lt;p&gt;So I stopped aiming at the head term and put effort into vertical template pages and guides. Impressions without clicks became my to-do list: each one is a page that's almost ranking.&lt;/p&gt;

&lt;p&gt;If I started again I'd do this keyword check &lt;strong&gt;before&lt;/strong&gt; writing code, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Bonus bug: D1 rejected a query that SQLite was happy with
&lt;/h2&gt;

&lt;p&gt;Last night my dashboard 500'd in preview while 992 local tests passed. The "Needs you" panel used one query with seven &lt;code&gt;SELECT&lt;/code&gt;s joined by &lt;code&gt;UNION ALL&lt;/code&gt;. D1 returned &lt;code&gt;too many terms in compound SELECT&lt;/code&gt;. Local SQLite allows far more terms; D1 didn't. The fix was splitting it into two smaller queries, making each dashboard card fail independently ("temporarily unavailable", never a fake &lt;code&gt;0&lt;/code&gt;), and adding a test that rejects large compound selects. &lt;strong&gt;Local SQLite is not D1.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Prefer a small, true signal over a big, sometimes-false one.&lt;/li&gt;
&lt;li&gt;With payments, design so the money never touches you.&lt;/li&gt;
&lt;li&gt;Separate hosts when indexing rules conflict; noindex isn't security.&lt;/li&gt;
&lt;li&gt;Bound your analytics schema before it bounds you.&lt;/li&gt;
&lt;li&gt;Check keyword difficulty before you build, not after.&lt;/li&gt;
&lt;li&gt;Test against the real database runtime.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Invosmith lets you send invoices, quotes and credit notes, bill in stages, see when an invoice is viewed, send automatic reminders, and get paid by card straight to your own Stripe account. Invosmith takes no cut; you only pay Stripe's standard processing fee. Free invoice PDFs, no signup: &lt;a href="https://invosmith.com" rel="noopener noreferrer"&gt;invosmith.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>nextjs</category>
      <category>saas</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
