<?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: mattewens</title>
    <description>The latest articles on DEV Community by mattewens (@mattewens).</description>
    <link>https://dev.to/mattewens</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%2F4068803%2Fe966f439-3e56-427a-8838-3c01f6e7f326.png</url>
      <title>DEV Community: mattewens</title>
      <link>https://dev.to/mattewens</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mattewens"/>
    <language>en</language>
    <item>
      <title>I priced every voice-AI stack for a 10k-minute month - the results surprised me</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Wed, 26 Aug 2026 15:03:34 +0000</pubDate>
      <link>https://dev.to/mattewens/i-priced-every-voice-ai-stack-for-a-10k-minute-month-the-results-surprised-me-2037</link>
      <guid>https://dev.to/mattewens/i-priced-every-voice-ai-stack-for-a-10k-minute-month-the-results-surprised-me-2037</guid>
      <description>&lt;p&gt;I priced every voice-AI stack for a 10k-minute month. The results surprised me&lt;/p&gt;

&lt;p&gt;I was working through pricing for a voice-agent SaaS and got annoyed by how hard it is to answer one basic question.&lt;/p&gt;

&lt;p&gt;If I run 10,000 minutes of calls in a month, what do I actually pay?&lt;/p&gt;

&lt;p&gt;Not the headline platform fee. Not the nice landing-page number. The real stack cost after you add the phone line, speech-to-text, text-to-speech, and the voice agent provider.&lt;/p&gt;

&lt;p&gt;So I put Vapi, Retell, Bland, Twilio Voice, and OpenAI Realtime into the same model and ran a simple 10k-minute month.&lt;/p&gt;

&lt;p&gt;The short version is that the cheapest-looking option is not always cheapest. Once you add STT and TTS, the gap moves around fast.&lt;/p&gt;

&lt;p&gt;Here is the shape of the comparison I used.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;What is included&lt;/th&gt;
&lt;th&gt;What you still need to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vapi&lt;/td&gt;
&lt;td&gt;orchestration layer for voice agents&lt;/td&gt;
&lt;td&gt;model, voice, telephony and usage assumptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retell&lt;/td&gt;
&lt;td&gt;voice agent platform pricing&lt;/td&gt;
&lt;td&gt;included minutes and overage rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bland&lt;/td&gt;
&lt;td&gt;voice automation platform pricing&lt;/td&gt;
&lt;td&gt;campaign style, telephony, and concurrency limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio Voice&lt;/td&gt;
&lt;td&gt;raw telephony building block&lt;/td&gt;
&lt;td&gt;STT, TTS, LLM, orchestration, storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Realtime&lt;/td&gt;
&lt;td&gt;direct realtime model path&lt;/td&gt;
&lt;td&gt;phone carrier, call control, app logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mistake I see is comparing Vapi or Retell directly against Twilio Voice. That is not the same product. Twilio is mostly the carrier and programmable voice layer. If you use it raw, you still need the agent brain, streaming, speech recognition, speech generation, logs, retry logic, billing, and all the boring glue.&lt;/p&gt;

&lt;p&gt;That glue has a cost even if the invoice does not show up as one line item.&lt;/p&gt;

&lt;p&gt;For a 10k-minute month, the calculator makes you separate four questions.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How many minutes are inbound vs outbound?&lt;/li&gt;
&lt;li&gt;Which provider charges per minute on top of model cost?&lt;/li&gt;
&lt;li&gt;Is speech-to-text bundled or separate?&lt;/li&gt;
&lt;li&gt;Is text-to-speech bundled or separate?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The surprising bit was how often the raw telephony path only wins if you already have the engineering time to own the whole stack. If you need to ship a product this week, the platform markup can be cheaper than spending two weeks debugging call streams and usage reconciliation.&lt;/p&gt;

&lt;p&gt;The other surprise was that voice choice matters more than people expect. A nice neural voice can turn a cheap call path into a much more expensive one. Same with transcription. If you pick a premium STT provider and a premium TTS provider, your per-minute economics change before the agent provider has even charged you.&lt;/p&gt;

&lt;p&gt;Here is a simplified example.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost bucket&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Telephony&lt;/td&gt;
&lt;td&gt;every minute needs a phone network path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice agent platform&lt;/td&gt;
&lt;td&gt;orchestration, latency handling, tools, call state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STT&lt;/td&gt;
&lt;td&gt;turns caller audio into text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM or realtime model&lt;/td&gt;
&lt;td&gt;reasoning and response generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS&lt;/td&gt;
&lt;td&gt;turns responses back into audio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage and logs&lt;/td&gt;
&lt;td&gt;recordings, transcripts, analytics, compliance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pricing pages make each bucket look small in isolation. The month total is where it gets real.&lt;/p&gt;

&lt;p&gt;This matters if you are selling voice agents to customers. A client does not care that Twilio was cheap if the full product loses money at 2,000 minutes. They also do not care that a platform is elegant if their expected call volume makes the margin disappear.&lt;/p&gt;

&lt;p&gt;The sane way to price this is backwards. Start with the customer plan, estimate expected call minutes, add a safety margin for spikes, then choose the stack. Do not choose the stack first and hope the unit economics work later.&lt;/p&gt;

&lt;p&gt;For example, if you sell a $199 per month voice-agent plan and the customer uses 10,000 minutes, your gross margin can get wrecked. If you sell a $999 plan with usage caps and overages, the same provider might be fine. The product packaging matters as much as the infrastructure provider.&lt;/p&gt;

&lt;p&gt;I also think more voice-AI demos should publish their assumed monthly minutes. A demo that handles five test calls tells you almost nothing about cost at real usage. The calculator forced me to make those assumptions explicit, which made the provider comparison much cleaner.&lt;/p&gt;

&lt;p&gt;None of this means one provider is always better. Vapi can make sense when you want a developer-first orchestration layer. Retell can make sense if its bundled path matches your use case. Bland can make sense for certain outbound workflows. Twilio plus your own stack can make sense when you have the engineering team and need control. OpenAI Realtime can make sense when you want to build close to the model.&lt;/p&gt;

&lt;p&gt;The point is that you should compare full stack cost, not logo pricing.&lt;/p&gt;

&lt;p&gt;I put this into a calculator so anyone can plug their own numbers: &lt;a href="https://callforge.dev/calculator" rel="noopener noreferrer"&gt;https://callforge.dev/calculator&lt;/a&gt;&lt;/p&gt;

</description>
      <category>vapi</category>
      <category>voice</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Rebuilt My Vapi Backend Three Times - Here's What I Learned</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Sun, 23 Aug 2026 11:14:02 +0000</pubDate>
      <link>https://dev.to/mattewens/i-rebuilt-my-vapi-backend-three-times-heres-what-i-learned-djh</link>
      <guid>https://dev.to/mattewens/i-rebuilt-my-vapi-backend-three-times-heres-what-i-learned-djh</guid>
      <description>&lt;p&gt;I rebuilt my Vapi backend three times before I admitted the problem was not Vapi. The problem was the system around it.&lt;/p&gt;

&lt;p&gt;The first version was a demo backend. It created an assistant, accepted a webhook, and stored a few call records. That was enough to impress myself and completely insufficient for a customer. The second version added auth and Stripe, but tenant boundaries were bolted on after the fact. The third version finally treated voice infrastructure like SaaS infrastructure.&lt;/p&gt;

&lt;p&gt;That sounds obvious once you say it. It was not obvious when the prototype was working and I wanted to move fast.&lt;/p&gt;

&lt;p&gt;The demo backend trap&lt;/p&gt;

&lt;p&gt;A Vapi prototype can be small. You need an assistant config, a phone number, a webhook URL, and some glue. You can get something speaking to a caller in an afternoon.&lt;/p&gt;

&lt;p&gt;The trap is that the prototype teaches you the wrong shape for the app. A single assistant becomes a global assistant. A single webhook handler becomes a pile of conditional logic. A single Stripe customer becomes a note in your database that you promise to clean up later.&lt;/p&gt;

&lt;p&gt;Later usually means after the first real customer has already created data you cannot casually migrate.&lt;/p&gt;

&lt;p&gt;The mistake I made was starting with calls. The better starting point is tenants.&lt;/p&gt;

&lt;p&gt;Every object needs an owner&lt;/p&gt;

&lt;p&gt;Voice SaaS gets messy when a call, phone number, assistant, tool, transcript, invoice item, and webhook event are not all tied back to the same account. You need that relationship before you need a clever prompt editor.&lt;/p&gt;

&lt;p&gt;The architecture that held up looked like this:&lt;/p&gt;

&lt;p&gt;User -&amp;gt; Tenant -&amp;gt; Assistant -&amp;gt; Phone Number -&amp;gt; Call -&amp;gt; Usage Event -&amp;gt; Invoice Item&lt;/p&gt;

&lt;p&gt;Vapi sits in the middle of the runtime path, but your app owns the commercial and permission model. That distinction matters. Vapi can run the call. Your backend needs to decide who is allowed to edit the assistant, who pays for the minutes, and where the post-call data lands.&lt;/p&gt;

&lt;p&gt;A simple route shape helped:&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;/api/tenants/:tenantId/assistants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireUser&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenant&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;some&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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;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;tenant&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&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;404&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;tenant_not_found&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;assistant&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;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistants&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;name&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="nx"&gt;name&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&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="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;transcriber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deepgram&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;11labs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;voiceId&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="nx"&gt;voiceId&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistant&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;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;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenant&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;vapiAssistantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;assistant&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;name&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="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="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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;assistant&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the SDK call. It is the tenant check before the SDK call and the local record after it.&lt;/p&gt;

&lt;p&gt;Webhooks are not logs&lt;/p&gt;

&lt;p&gt;My first webhook handler wrote whatever came in to a JSON column and moved on. That felt flexible. It was really just postponing decisions.&lt;/p&gt;

&lt;p&gt;A production webhook needs three boring properties: verification, idempotency, and routing. Verification keeps random internet noise out. Idempotency stops retries from double billing. Routing maps the event back to the tenant before any side effect happens.&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;/webhooks/vapi&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&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;id&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;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&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="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;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exists&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&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;exists&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callId&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;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vapiCallId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;callId&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvent&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&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;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;message&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;end-of-call-report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;call&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&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;where&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="nx"&gt;call&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;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;endedAt&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="na"&gt;summary&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&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;ok&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is less exciting than a dashboard. It is also what separates a tool from a product.&lt;/p&gt;

&lt;p&gt;Billing has to follow the call lifecycle&lt;/p&gt;

&lt;p&gt;Metered billing is easy to describe and annoying to implement late. You need to know when usage is final, what unit you bill on, and how you handle provider retries. I found it cleaner to create internal usage events first, then sync those to Stripe.&lt;/p&gt;

&lt;p&gt;Do not use Stripe as your source of truth for raw call state. Let Stripe be the billing rail. Your database should know the call duration, tenant, provider call ID, billing status, and invoice item reference.&lt;/p&gt;

&lt;p&gt;A practical pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store call start when Vapi creates or reports the call.&lt;/li&gt;
&lt;li&gt;Store call end and duration from the end-of-call event.&lt;/li&gt;
&lt;li&gt;Create one usage event with a deterministic idempotency key.&lt;/li&gt;
&lt;li&gt;Sync to Stripe once.&lt;/li&gt;
&lt;li&gt;Mark the usage event as billed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That lets you retry billing safely without rewriting call history.&lt;/p&gt;

&lt;p&gt;Prompt templates are product surface, not config&lt;/p&gt;

&lt;p&gt;In version one, prompts lived inside code. In version two, they lived in a text box. Both were wrong.&lt;/p&gt;

&lt;p&gt;Customers do not want raw prompt control as much as builders think they do. They want safe knobs: greeting, business facts, escalation rules, booking rules, disallowed claims, tone, and fallback behaviour. Store those as structured fields and compile the assistant prompt from them.&lt;/p&gt;

&lt;p&gt;That gives you versioning, previews, and rollback. It also makes support easier because you can see which field caused the assistant to behave differently.&lt;/p&gt;

&lt;p&gt;The rebuild I would avoid now&lt;/p&gt;

&lt;p&gt;If I were starting again, I would not begin with the voice provider integration. I would begin with the boring SaaS skeleton:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tenants and memberships&lt;/li&gt;
&lt;li&gt;assistant records mapped to provider IDs&lt;/li&gt;
&lt;li&gt;phone number ownership&lt;/li&gt;
&lt;li&gt;webhook event storage&lt;/li&gt;
&lt;li&gt;usage events&lt;/li&gt;
&lt;li&gt;Stripe customer mapping&lt;/li&gt;
&lt;li&gt;audit logs for assistant changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I would add Vapi as a provider module, not as the centre of the whole app.&lt;/p&gt;

&lt;p&gt;That sounds slower for the first demo. It is faster by the time the second customer asks for their own phone number, their own prompt, their own billing, and their own call history.&lt;/p&gt;

&lt;p&gt;The main lesson&lt;/p&gt;

&lt;p&gt;Voice AI demos are deceptively close to products. The gap is not speech quality. The gap is ownership, billing, retries, permissions, and lifecycle state.&lt;/p&gt;

&lt;p&gt;If you are building on Vapi, Retell, Bland, or any similar provider, treat the provider as runtime infrastructure. Your app still needs to be the system of record.&lt;/p&gt;

&lt;p&gt;I rebuilt this stack enough times that I packaged the pattern into Callforge. If you would rather skip the rebuilds, the public preview is at &lt;a href="https://callforge.dev/preview.html" rel="noopener noreferrer"&gt;https://callforge.dev/preview.html&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vapi</category>
      <category>voice</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What Breaks When Your Voice Agent Hits Customer #10</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Sun, 16 Aug 2026 00:53:09 +0000</pubDate>
      <link>https://dev.to/mattewens/what-breaks-when-your-voice-agent-hits-customer-10-47of</link>
      <guid>https://dev.to/mattewens/what-breaks-when-your-voice-agent-hits-customer-10-47of</guid>
      <description>&lt;p&gt;&lt;em&gt;Three real failures from shipping voice SaaS, and the boring fixes nobody demos&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Customer #1 is easy. One phone number, one Vapi assistant, one Stripe subscription you clicked together by hand in the dashboard. Your demo works. Landing page is live. You're ready to scale.&lt;/p&gt;

&lt;p&gt;Customer #10 is where it gets interesting.&lt;/p&gt;

&lt;p&gt;Same code. Same config. Suddenly everything's on fire. I've hit this wall three times now, building voice-agent SaaS for consulting clients, and honestly the pattern's identical every single time. Here's what actually breaks when you cross that threshold, plus the unsexy infra work that saves you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Provisioning Problem
&lt;/h2&gt;

&lt;p&gt;Your first customer gets a Twilio number you bought manually. You hardcoded it into the env vars. It's fine. They're happy. Life is good.&lt;/p&gt;

&lt;p&gt;Your tenth customer needs a number too. So you buy one, drop it in the config, redeploy. Still fine. Annoying, but fine.&lt;/p&gt;

&lt;p&gt;Your twentieth customer hits at 11pm on a Sunday. You're at dinner. They're in a different timezone and their number isn't working because something broke in your manual process, and now you're debugging Twilio logs on your phone while the restaurant staff give you that look. You know the one.&lt;/p&gt;

&lt;p&gt;The real fix isn't sexy. It's a provisioning API that buys numbers automatically. Health checks that retry failed purchases. A pool of warm numbers sitting there ready to assign. All the stuff you skip in the tutorial because "we'll add that later."&lt;/p&gt;

&lt;p&gt;Later never comes. Until it burns you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring fix:&lt;/strong&gt;&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;// Auto-provision numbers with health checks&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;provisionNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&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;pool&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;getWarmNumberPool&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&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;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incomingPhoneNumbers&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;areaCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;415&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;voiceUrl&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;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/webhook/voice/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;customerId&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;// Health check before assignment&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;validateNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phoneNumber&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;assignToCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Replenish pool&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;pool&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;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&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;warmNumberPool&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Billing Revelation
&lt;/h2&gt;

&lt;p&gt;Look, seat-based pricing is a trap for voice. Your customer doesn't care about seats. They care about minutes. One agent handling 1000 calls is a completely different beast from ten agents handling ten calls each. Seat pricing makes zero sense here.&lt;/p&gt;

&lt;p&gt;I learned this the expensive way. A client had a customer go viral on TikTok. Their phone line just blew up. Weekend call volume hit 50,000 minutes. Under seat pricing? That customer paid $49. Their Twilio bill was $1,247.&lt;/p&gt;

&lt;p&gt;That's a fun Monday morning conversation.&lt;/p&gt;

&lt;p&gt;Real voice SaaS needs metered billing. Twilio call-status webhooks feeding into Stripe usage records, per-minute billing that tracks what people actually use, not some artificial seat count. It's tedious to build. Everyone skips it. Everyone regrets it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring fix:&lt;/strong&gt;&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;// Webhook handler for usage-based billing&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;/webhook/twilio/call-status&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CallSid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CallDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CallStatus&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="nx"&gt;CallStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&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;customer&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;getCustomerByCallSid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CallSid&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;minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CallDuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Push to Stripe usage records&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUsageRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeSubscriptionItemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;minutes&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&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;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;increment&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Compliance Trap
&lt;/h2&gt;

&lt;p&gt;GDPR sounds theoretical until your first erasure request lands in your inbox. "Please delete all my data."&lt;/p&gt;

&lt;p&gt;Simple, right? Except your recordings are scattered across three S3 buckets. Your call logs live in Twilio. Your transcripts are sitting in your LLM provider. Your customer database is in Postgres. And you never built a way to connect any of it.&lt;/p&gt;

&lt;p&gt;So now you're grepping through logs at midnight trying to find every trace of one customer, while their lawyer sends increasingly pointed emails. Cool cool cool.&lt;/p&gt;

&lt;p&gt;The fix is per-tenant isolation from day one. Separate buckets, consent gates, erasure hooks that cascade through your data model. It's boring infrastructure work. Absolutely critical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring fix:&lt;/strong&gt;&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;// Per-tenant storage with cascade erasure&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;eraseCustomerData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&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;tenant&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;getTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// S3: Delete all objects in tenant bucket&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteObjects&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`callforge-recordings-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucketSuffix&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;Delete&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;listAllObjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucketSuffix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Twilio: Delete recordings via API&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recordings&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;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordings&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;dateCreatedAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dateCreatedBefore&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="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&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;recordings&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="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="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

  &lt;span class="c1"&gt;// LLM provider: Delete stored transcripts&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deleteLLMTranscripts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;llmSessionIds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Finally: Remove from database&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&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="nx"&gt;customerId&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;h2&gt;
  
  
  What This Means for Your Stack
&lt;/h2&gt;

&lt;p&gt;Here's the thing. These aren't edge cases. They're the difference between a demo and a business. Every voice-agent tutorial out there shows you how to wire Vapi to Twilio in twenty lines of code. Nobody shows you the three months of infrastructure work that follows.&lt;/p&gt;

&lt;p&gt;We learned this by building voice SaaS for clients, and by tearing down 25 open-source voice-agent repos to see where everyone skips the exact same steps. Spoiler: it's always the same steps.&lt;/p&gt;

&lt;p&gt;If you're serious about shipping voice agents that don't fall over at customer #10, start with the boring stuff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automated provisioning with health checks&lt;/li&gt;
&lt;li&gt;Metered billing tied to actual usage&lt;/li&gt;
&lt;li&gt;Per-tenant data isolation with erasure hooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flashy features can wait. Your 3am self will thank you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Check the teardown:&lt;/strong&gt; I analyzed 25 voice-agent repos to find exactly where teams skip this infrastructure. See the full breakdown at &lt;a href="https://callforge.dev/teardown" rel="noopener noreferrer"&gt;callforge.dev/teardown&lt;/a&gt;, or grab the boilerplate that already handles provisioning, billing, and compliance out of the box.&lt;/p&gt;

</description>
      <category>voice</category>
      <category>saas</category>
      <category>twilio</category>
      <category>startup</category>
    </item>
    <item>
      <title>Linear's Waitlist Playbook: How to Build Hype Without Being Annoying</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Sat, 15 Aug 2026 23:51:00 +0000</pubDate>
      <link>https://dev.to/mattewens/linears-waitlist-playbook-how-to-build-hype-without-being-annoying-146g</link>
      <guid>https://dev.to/mattewens/linears-waitlist-playbook-how-to-build-hype-without-being-annoying-146g</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Linear didn't treat the waitlist like a sad little email capture box. They treated it like the first product surface. The result was scarcity, social proof, and a bunch of devs convincing each other they needed access before most of them had even touched the app.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pre-Launch Breadcrumb Trail
&lt;/h2&gt;

&lt;p&gt;Linear started building demand months before broad availability. Not with some grand "coming soon" campaign. With breadcrumbs.&lt;/p&gt;

&lt;p&gt;Team members posted UI prototypes. Screenshots appeared with casual "wip" captions that somehow showed exactly enough to make design Twitter lean in. Founders talked about engineering decisions instead of dumping feature lists. For a while, there wasn't even a heavy waitlist push.&lt;/p&gt;

&lt;p&gt;That's the clever bit. They made the conversation feel discovered, not forced.&lt;/p&gt;

&lt;p&gt;Curiosity did the work. Everyone could tell something polished was coming, but nobody knew when they'd get it. That uncertainty became fuel instead of friction. Honestly, it probably made the product feel more real, because the team was showing receipts instead of polishing a launch slogan.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Waitlist Launch: Small Pond, Big Splash
&lt;/h2&gt;

&lt;p&gt;When Linear finally opened access, they didn't sprint straight to Product Hunt or buy ads like a panicked B2B startup with a new logo and too much coffee.&lt;/p&gt;

&lt;p&gt;They started with a tight audience: founder followers, team networks, and design-heavy circles that cared about craft. Then they added referrals. Get friends to sign up, move up the queue.&lt;/p&gt;

&lt;p&gt;That mechanism matters because the product's audience already liked tasteful tools and status-y early access. Linear didn't need a giant top-of-funnel. It needed the right people to feel like they were early.&lt;/p&gt;

&lt;p&gt;Twenty thousand signups from a small base sounds wild, but the loop was clean: want access, invite friends, signal taste, repeat. Yeah, that's a pretty good loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Referral Mechanics Scaled Because They Sold Access
&lt;/h2&gt;

&lt;p&gt;The reward wasn't a discount. Thank god. Discount-driven referrals tend to attract people optimizing for coupons, not people who care about the product.&lt;/p&gt;

&lt;p&gt;Linear gamified access instead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Reward&lt;/th&gt;
&lt;th&gt;Psychology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sign up&lt;/td&gt;
&lt;td&gt;Place in queue&lt;/td&gt;
&lt;td&gt;Commitment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refer 1 friend&lt;/td&gt;
&lt;td&gt;Position bump&lt;/td&gt;
&lt;td&gt;Investment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refer 3 friends&lt;/td&gt;
&lt;td&gt;Private beta access&lt;/td&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refer 10+ friends&lt;/td&gt;
&lt;td&gt;Founding-user badge&lt;/td&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The brilliant part is that each step deepened identity. You weren't just waiting. You were becoming the kind of person who found Linear early, invited the right people, and maybe got let in before everyone else.&lt;/p&gt;

&lt;p&gt;A little ridiculous? Sure. Also very human.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Invite Email Respected the Moment
&lt;/h2&gt;

&lt;p&gt;The invite email didn't waste time. "You're in" did the heavy lifting. A single download link sat near the top. A screenshot reminded you what you'd just been given. "Questions? Reply to this email" made access feel personal, not automated.&lt;/p&gt;

&lt;p&gt;That last detail punches above its weight. Early users want proximity. They want to feel like their feedback matters and that a real person might actually read the reply.&lt;/p&gt;

&lt;p&gt;Linear also throttled invites. Weekly batches kept the conversation alive, and every new wave created fresh social proof. If everyone gets in immediately, the waitlist stops being a waitlist and becomes a form with better typography. Look, sometimes the delay is the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Anti-Patterns They Dodged
&lt;/h2&gt;

&lt;p&gt;Most waitlists are where momentum goes to die. You enter an email, get a limp "we'll let you know," and then hear nothing until a launch email lands three months later like a damp napkin.&lt;/p&gt;

&lt;p&gt;Linear avoided that by keeping the product visible during the wait. Development updates, screenshots, replies, invite waves, and user posts all made the wait feel active.&lt;/p&gt;

&lt;p&gt;They also avoided the usual mistakes. There was no generic dead-end confirmation, no single flat reward, no total silence, no bland access email, and no instant full availability that killed scarcity.&lt;/p&gt;

&lt;p&gt;The wait had texture. That's rare.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Twitter Amplification Loop
&lt;/h2&gt;

&lt;p&gt;Linear's founding team posted constantly during the waitlist phase, but it didn't feel like scheduled content sludge. It felt like builders showing receipts.&lt;/p&gt;

&lt;p&gt;They shared screenshots of real usage, talked through engineering problems, replied quickly, and celebrated users who got invites. Every "I'm in" tweet became proof that people wanted the thing. Every batch release became another excuse for the network to talk about it.&lt;/p&gt;

&lt;p&gt;That's the loop: product tease creates desire, invite creates status, status creates posts, posts create more desire.&lt;/p&gt;

&lt;p&gt;Simple. Brutal. Effective.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Launch Day Strategy
&lt;/h2&gt;

&lt;p&gt;By the time Linear launched publicly, the launch wasn't really the beginning. Existing users had habits. Waitlist veterans had identity investment. Product Hunt had real stories to point at instead of promises.&lt;/p&gt;

&lt;p&gt;That's why it didn't land to crickets. They weren't announcing a mystery product to strangers. They were widening access to something a specific crowd already wanted.&lt;/p&gt;

&lt;p&gt;Launch day was basically the victory lap. Kind of annoying, because when it works this cleanly it looks obvious afterward.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Steal for Your Own Waitlist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Linear Technique&lt;/th&gt;
&lt;th&gt;Your Application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Referral tiers&lt;/td&gt;
&lt;td&gt;"Share with 2 dev friends, get priority access"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breadcrumb tease&lt;/td&gt;
&lt;td&gt;Post screenshots before public beta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering transparency&lt;/td&gt;
&lt;td&gt;Thread on why your architecture works this way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Founder accessibility&lt;/td&gt;
&lt;td&gt;Reply personally to early signups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch throttling&lt;/td&gt;
&lt;td&gt;Weekly onboarding waves instead of instant access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User celebration&lt;/td&gt;
&lt;td&gt;Repost every "got my invite" mention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key is not to fake exclusivity. If access is limited, make the limitation useful: better onboarding, faster feedback, tighter product loops.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conversion Score: 9/10
&lt;/h2&gt;

&lt;p&gt;Linear made the waitlist feel like part of the product instead of the waiting room. That's the whole game.&lt;/p&gt;

&lt;p&gt;The risk, obviously, is over-hype. Scarcity creates expectations, and expectations are expensive. Linear got away with it because the product shipped at the level the story promised.&lt;/p&gt;

&lt;p&gt;Annoying how clean that is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This teardown is part of a series on developer tool conversion.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you run a waitlist that actually worked? Or one that flopped? Tell us what happened in the comments.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>product</category>
      <category>growth</category>
      <category>waitlist</category>
      <category>saas</category>
    </item>
    <item>
      <title>Why Stripe's API Docs Convert Better Than Your Landing Page</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:51:08 +0000</pubDate>
      <link>https://dev.to/mattewens/why-stripes-api-docs-convert-better-than-your-landing-page-13ac</link>
      <guid>https://dev.to/mattewens/why-stripes-api-docs-convert-better-than-your-landing-page-13ac</guid>
      <description>&lt;p&gt;Stripe's API docs aren't just reference material. They're a conversion funnel wearing a hoodie. You arrive with a vague intent, copy a snippet, get a charge working, and suddenly Stripe feels like the obvious choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three-Panel Layout Works Because Devs Skim
&lt;/h2&gt;

&lt;p&gt;Most docs still behave like a blog post with a sidebar. Stripe understood the assignment better: navigation on the left, explanation in the middle, request and response examples on the right.&lt;/p&gt;

&lt;p&gt;That's not decoration. It's workflow design.&lt;/p&gt;

&lt;p&gt;Developers don't read docs like novels. We jump around, search for the endpoint, steal the code, run it, swear once, then come back for the parameter we missed. Stripe supports that messy loop instead of pretending everyone is calmly progressing through Chapter 1.&lt;/p&gt;

&lt;p&gt;The layout lets you orient, understand, and act without changing context. That sounds small until you've used docs that make you open six tabs just to send one request. Yeah, those still exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interactive Code Blocks Remove the First Excuse
&lt;/h2&gt;

&lt;p&gt;Stripe's code examples are built for action. Your API key can be injected. Every block has copy-to-clipboard. Language tabs remember what you picked. The examples are runnable, not museum pieces.&lt;/p&gt;

&lt;p&gt;This is where the conversion happens. Not in a hero headline. Not in a fancy "for ambitious teams" paragraph. It happens when a dev gets a working API call in under a minute and thinks, "Well, that was easy."&lt;/p&gt;

&lt;p&gt;Momentum is underrated. Once someone has a working charge, ripping Stripe back out feels more annoying than continuing.&lt;/p&gt;

&lt;p&gt;Sneaky? A little. Effective? Absolutely.&lt;/p&gt;

&lt;h2&gt;
  
  
  They Avoid the Classic Quickstart Faceplant
&lt;/h2&gt;

&lt;p&gt;A lot of API docs start like this: install the SDK, learn authentication, read the core concepts, understand the object model, then maybe you get to make a request if you're still awake.&lt;/p&gt;

&lt;p&gt;Stripe flips it. First, make a charge. Then understand what happened. Then dig into setup and edge cases.&lt;/p&gt;

&lt;p&gt;That ordering matters because the first win buys patience. After the dopamine hit, people are more willing to read the boring-but-important details. Before it, those details feel like homework.&lt;/p&gt;

&lt;p&gt;Your docs should copy this shamelessly. Don't open with architecture. Open with "get your first API call working in 30 seconds," then explain the machinery after the user has seen output. Honestly, nobody complains because the docs gave them a win too quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search That Feels Like It Knows What You Meant
&lt;/h2&gt;

&lt;p&gt;Stripe search is doing more than matching keywords. It handles fuzzy intent, pushes common actions to the top, links directly into useful code samples, and remembers language preferences.&lt;/p&gt;

&lt;p&gt;That's how docs become muscle memory. You stop thinking "where is that page?" and start typing the intent. Create a charge. Set up a webhook. Refund a payment. Boom, you're there.&lt;/p&gt;

&lt;p&gt;Good search is one of those unglamorous features that makes the whole product feel sharper. Bad search makes even great docs feel like a junk drawer. Look, if search can't find "webhook retry," people assume the rest of the product has the same weird gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use-Case Journeys Beat Pure Reference
&lt;/h2&gt;

&lt;p&gt;Reference docs answer "what does this parameter do?" That's necessary, but it's not enough.&lt;/p&gt;

&lt;p&gt;Stripe also gives paths around real jobs: accept a payment, set up a subscription, build a marketplace, handle refunds. Those guides map to what the dev is trying to ship, not how Stripe happens to organize its internal nouns.&lt;/p&gt;

&lt;p&gt;That distinction is huge. Users don't wake up wanting to "instantiate a PaymentIntent object." They want to take money without breaking production.&lt;/p&gt;

&lt;p&gt;You need the same split. Keep the reference clean, but build guides around actual jobs: set up authentication, handle errors, monitor rate limits, migrate from a competitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trust Infrastructure Is Everywhere
&lt;/h2&gt;

&lt;p&gt;Every page quietly says, "someone maintains this." API status is visible. Security posture is visible. Changelog freshness is visible. Feedback buttons are right there.&lt;/p&gt;

&lt;p&gt;None of that is flashy, and that's the point. For payments, abandonment fear is real. If your docs look stale, people assume the SDK is stale, support is slow, and the integration will bite them later.&lt;/p&gt;

&lt;p&gt;Stripe keeps sending the opposite signal: we're here, we're watching, and you probably won't get stranded. Basically, it makes the boring parts feel cared for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Steal for Your Own Docs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stripe Technique&lt;/th&gt;
&lt;th&gt;Your Application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Three-panel layout&lt;/td&gt;
&lt;td&gt;Left nav, middle explanation, right live API output preview&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Try-first quickstart&lt;/td&gt;
&lt;td&gt;"Get your first API call working in 30 seconds" before deep dives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive code&lt;/td&gt;
&lt;td&gt;Curl command populated with the user's real API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use-case paths&lt;/td&gt;
&lt;td&gt;Set up auth, handle errors, monitor limits, migrate data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feedback loops&lt;/td&gt;
&lt;td&gt;"Was this example useful?" on every code block&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The big lesson is that docs shouldn't merely explain the product. They should get the user to a small win before doubt has time to put its shoes on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversion Score: 9/10
&lt;/h2&gt;

&lt;p&gt;Stripe's docs did what marketing usually can't: they made integration feel inevitable. The only ding is depth. Simple use cases can still feel buried if you don't already know the payments domain.&lt;/p&gt;

&lt;p&gt;But the invisible win is massive. The docs reduce support load while increasing conversion. That's the good stuff.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This teardown is part of a series on developer tool conversion.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the worst API documentation you've had to wrestle with? Name and shame in the comments.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>documentation</category>
      <category>api</category>
      <category>stripe</category>
      <category>saas</category>
    </item>
    <item>
      <title>Linear's Waitlist Playbook: How to Build Hype Without Being Annoying</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:51:10 +0000</pubDate>
      <link>https://dev.to/mattewens/linears-waitlist-playbook-how-to-build-hype-without-being-annoying-33a2</link>
      <guid>https://dev.to/mattewens/linears-waitlist-playbook-how-to-build-hype-without-being-annoying-33a2</guid>
      <description>&lt;p&gt;Linear didn't treat the waitlist like a sad little email capture box. They treated it like the first product surface. The result was scarcity, social proof, and a bunch of devs convincing each other they needed access before most of them had even touched the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Launch Breadcrumb Trail
&lt;/h2&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%2Fi.imgur.com%2FPLACEHOLDER_LINEAR_LANDING.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%2Fi.imgur.com%2FPLACEHOLDER_LINEAR_LANDING.png" alt="Linear early access landing page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Linear started building demand months before broad availability. Not with some grand "coming soon" campaign. With breadcrumbs.&lt;/p&gt;

&lt;p&gt;Team members posted UI prototypes. Screenshots appeared with casual "wip" captions that somehow showed exactly enough to make design Twitter lean in. Founders talked about engineering decisions instead of dumping feature lists. For a while, there wasn't even a heavy waitlist push.&lt;/p&gt;

&lt;p&gt;That's the clever bit. They made the conversation feel discovered, not forced.&lt;/p&gt;

&lt;p&gt;Curiosity did the work. Everyone could tell something polished was coming, but nobody knew when they'd get it. That uncertainty became fuel instead of friction. Honestly, it probably made the product feel more real, because the team was showing receipts instead of polishing a launch slogan.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Waitlist Launch: Small Pond, Big Splash
&lt;/h2&gt;

&lt;p&gt;When Linear finally opened access, they didn't sprint straight to Product Hunt or buy ads like a panicked B2B startup with a new logo and too much coffee.&lt;/p&gt;

&lt;p&gt;They started with a tight audience: founder followers, team networks, and design-heavy circles that cared about craft. Then they added referrals. Get friends to sign up, move up the queue.&lt;/p&gt;

&lt;p&gt;That mechanism matters because the product's audience already liked tasteful tools and status-y early access. Linear didn't need a giant top-of-funnel. It needed the right people to feel like they were early.&lt;/p&gt;

&lt;p&gt;Twenty thousand signups from a small base sounds wild, but the loop was clean: want access, invite friends, signal taste, repeat. Yeah, that's a pretty good loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Referral Mechanics Scaled Because They Sold Access
&lt;/h2&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%2Fi.imgur.com%2FPLACEHOLDER_LINEAR_REFERRAL.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%2Fi.imgur.com%2FPLACEHOLDER_LINEAR_REFERRAL.png" alt="Linear referral flow" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reward wasn't a discount. Thank god. Discount-driven referrals tend to attract people optimizing for coupons, not people who care about the product.&lt;/p&gt;

&lt;p&gt;Linear gamified access instead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Reward&lt;/th&gt;
&lt;th&gt;Psychology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sign up&lt;/td&gt;
&lt;td&gt;Place in queue&lt;/td&gt;
&lt;td&gt;Commitment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refer 1 friend&lt;/td&gt;
&lt;td&gt;Position bump&lt;/td&gt;
&lt;td&gt;Investment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refer 3 friends&lt;/td&gt;
&lt;td&gt;Private beta access&lt;/td&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refer 10+ friends&lt;/td&gt;
&lt;td&gt;Founding-user badge&lt;/td&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The brilliant part is that each step deepened identity. You weren't just waiting. You were becoming the kind of person who found Linear early, invited the right people, and maybe got let in before everyone else.&lt;/p&gt;

&lt;p&gt;A little ridiculous? Sure. Also very human.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Invite Email Respected the Moment
&lt;/h2&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%2Fi.imgur.com%2FPLACEHOLDER_LINEAR_EMAIL.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%2Fi.imgur.com%2FPLACEHOLDER_LINEAR_EMAIL.png" alt="Linear waitlist email snippet" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The invite email didn't waste time. "You're in" did the heavy lifting. A single download link sat near the top. A screenshot reminded you what you'd just been given. "Questions? Reply to this email" made access feel personal, not automated.&lt;/p&gt;

&lt;p&gt;That last detail punches above its weight. Early users want proximity. They want to feel like their feedback matters and that a real person might actually read the reply.&lt;/p&gt;

&lt;p&gt;Linear also throttled invites. Weekly batches kept the conversation alive, and every new wave created fresh social proof. If everyone gets in immediately, the waitlist stops being a waitlist and becomes a form with better typography. Look, sometimes the delay is the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Anti-Patterns They Dodged
&lt;/h2&gt;

&lt;p&gt;Most waitlists are where momentum goes to die. You enter an email, get a limp "we'll let you know," and then hear nothing until a launch email lands three months later like a damp napkin.&lt;/p&gt;

&lt;p&gt;Linear avoided that by keeping the product visible during the wait. Development updates, screenshots, replies, invite waves, and user posts all made the wait feel active.&lt;/p&gt;

&lt;p&gt;They also avoided the usual mistakes. There was no generic dead-end confirmation, no single flat reward, no total silence, no bland access email, and no instant full availability that killed scarcity.&lt;/p&gt;

&lt;p&gt;The wait had texture. That's rare.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Twitter Amplification Loop
&lt;/h2&gt;

&lt;p&gt;Linear's founding team posted constantly during the waitlist phase, but it didn't feel like scheduled content sludge. It felt like builders showing receipts.&lt;/p&gt;

&lt;p&gt;They shared screenshots of real usage, talked through engineering problems, replied quickly, and celebrated users who got invites. Every "I'm in" tweet became proof that people wanted the thing. Every batch release became another excuse for the network to talk about it.&lt;/p&gt;

&lt;p&gt;That's the loop: product tease creates desire, invite creates status, status creates posts, posts create more desire.&lt;/p&gt;

&lt;p&gt;Simple. Brutal. Effective.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Launch Day Strategy
&lt;/h2&gt;

&lt;p&gt;By the time Linear launched publicly, the launch wasn't really the beginning. Existing users had habits. Waitlist veterans had identity investment. Product Hunt had real stories to point at instead of promises.&lt;/p&gt;

&lt;p&gt;That's why it didn't land to crickets. They weren't announcing a mystery product to strangers. They were widening access to something a specific crowd already wanted.&lt;/p&gt;

&lt;p&gt;Launch day was basically the victory lap. Kind of annoying, because when it works this cleanly it looks obvious afterward.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Steal for Your Own Waitlist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Linear Technique&lt;/th&gt;
&lt;th&gt;Your Application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Referral tiers&lt;/td&gt;
&lt;td&gt;"Share with 2 dev friends, get priority access"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breadcrumb tease&lt;/td&gt;
&lt;td&gt;Post screenshots before public beta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering transparency&lt;/td&gt;
&lt;td&gt;Thread on why your architecture works this way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Founder accessibility&lt;/td&gt;
&lt;td&gt;Reply personally to early signups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch throttling&lt;/td&gt;
&lt;td&gt;Weekly onboarding waves instead of instant access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User celebration&lt;/td&gt;
&lt;td&gt;Repost every "got my invite" mention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key is not to fake exclusivity. If access is limited, make the limitation useful: better onboarding, faster feedback, tighter product loops.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conversion Score: 9/10
&lt;/h2&gt;

&lt;p&gt;Linear made the waitlist feel like part of the product instead of the waiting room. That's the whole game.&lt;/p&gt;

&lt;p&gt;The risk, obviously, is over-hype. Scarcity creates expectations, and expectations are expensive. Linear got away with it because the product shipped at the level the story promised.&lt;/p&gt;

&lt;p&gt;Annoying how clean that is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This teardown is part of a series on developer tool conversion. Previously: &lt;a href="https://dev.tolink"&gt;Vercel's pricing psychology&lt;/a&gt; and &lt;a href="https://dev.tolink"&gt;Stripe's API docs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you run a waitlist that actually worked? Or one that flopped? Tell us what happened in the comments.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>product</category>
      <category>growth</category>
      <category>waitlist</category>
      <category>lineargroup</category>
    </item>
    <item>
      <title>How Vercel's Pricing Page Converts Developers (Teardown)</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Fri, 14 Aug 2026 17:04:22 +0000</pubDate>
      <link>https://dev.to/mattewens/how-vercels-pricing-page-converts-developers-teardown-27ao</link>
      <guid>https://dev.to/mattewens/how-vercels-pricing-page-converts-developers-teardown-27ao</guid>
      <description>&lt;p&gt;Vercel's pricing page isn't really selling servers. It's selling the feeling that you can ship tonight without babysitting infra at 2 a.m. That's the trick. And yeah, it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hook Above the Fold
&lt;/h2&gt;

&lt;p&gt;They put the free tier right up front. Not buried in a footnote. Not hidden behind a "start trial" button with a card form waiting to mug you.&lt;/p&gt;

&lt;p&gt;That matters because devs are allergic to pricing games. Honestly, the "Hobby" label is doing quiet work here. It doesn't say "free because you're broke." It says "this is for side projects, experiments, weird weekend builds, and all the other stuff devs actually care about."&lt;/p&gt;

&lt;p&gt;That's smart framing. You're not being downgraded into the cheap seat. You're being invited to start where you are.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three-Tier Dance
&lt;/h2&gt;

&lt;p&gt;Most SaaS pricing pages run the same Goldilocks routine: cheap plan, target plan, scary enterprise plan. Vercel does that, but with a better identity wrapper.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Frame&lt;/th&gt;
&lt;th&gt;Psychology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hobby&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;For hobbyists&lt;/td&gt;
&lt;td&gt;Zero-risk entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$20/mo&lt;/td&gt;
&lt;td&gt;For professionals&lt;/td&gt;
&lt;td&gt;Identity upgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Contact sales&lt;/td&gt;
&lt;td&gt;Status and procurement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The move isn't "pay for feature X." It's "graduate into the kind of builder you already think you are." Look, a lot of pricing pages miss this and drown you in usage limits before you've even decided whether the product belongs in your workflow.&lt;/p&gt;

&lt;p&gt;Vercel sells the self-image first. The feature table comes later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feature Tables That Pass the Glance Test
&lt;/h2&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%2Fi.imgur.com%2FPLACEHOLDER_VERCEL_FEATURES.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%2Fi.imgur.com%2FPLACEHOLDER_VERCEL_FEATURES.png" alt="Vercel pricing - feature comparison" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The comparison table is built for skimming, which is good because nobody lovingly reads pricing tables unless something has gone badly wrong.&lt;/p&gt;

&lt;p&gt;Green checks beat gray minus icons. "Everything in Hobby, plus..." makes Pro feel additive instead of punitive. And the "soft limits" language is doing more than it looks like, because it suggests judgment instead of a robot instantly smacking your account when traffic spikes.&lt;/p&gt;

&lt;p&gt;What they don't do is just as important. There's no "Vercel vs AWS" cage match. No competitor dunking. No desperate "save 40% compared to X" box. They own the frame and stay in it. Basically, they don't beg.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fair-Use Trust Play
&lt;/h2&gt;

&lt;p&gt;Vercel lists bandwidth limits instead of hiding them until you're already annoyed. That sounds basic, but plenty of hosting companies still treat billing surprises like a retention strategy. Spoiler: they're not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Soft limits, don't worry, we'll reach out first"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That line works because it acknowledges developer PTSD. Everyone has either eaten a surprise cloud bill or knows someone who has. By naming the boundary early and making it feel human, Vercel lowers the anxiety before it turns into an objection.&lt;/p&gt;

&lt;p&gt;Tiny copy. Big effect.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CTA Placement Pattern
&lt;/h2&gt;

&lt;p&gt;The calls to action are boring in the best way. "Get Started" appears where you'd expect it. The upgrade path stays visible. Enterprise gets "Talk to Sales," because at that point you're not impulse-buying, you're starting a procurement conversation.&lt;/p&gt;

&lt;p&gt;Here's the thing: the language progression matters. Start with deployment, end with relationship. They don't say "Buy Pro" because devs don't wake up wanting to buy a plan. They want to ship something and stop thinking about the plumbing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Steal for Your Own Pricing Page
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vercel Technique&lt;/th&gt;
&lt;th&gt;Your Application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Identity-based tiers&lt;/td&gt;
&lt;td&gt;Builder (free), Founder (paid), Agency (custom)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft-limit language&lt;/td&gt;
&lt;td&gt;"First 50 hours included, then $Y/hour. We'll warn you before it gets spicy."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast visual hierarchy&lt;/td&gt;
&lt;td&gt;Simple checkmarks and plain-English differences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action-first CTAs&lt;/td&gt;
&lt;td&gt;"Analyze My Repo" instead of "Subscribe"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The broader lesson is simple. Make the pricing page feel like a path to momentum, not a toll booth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conversion Score: 8/10
&lt;/h2&gt;

&lt;p&gt;Vercel loses a couple points because the Enterprise flow is vague. "Contact sales" is fine, but a rough scale estimator would keep more serious buyers moving instead of dumping them into a form.&lt;/p&gt;

&lt;p&gt;Still, the page is annoyingly good. The identity framing, plain developer language, and honest limit handling make it one of the cleaner dev-tool pricing pages out there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This teardown is part of a series on developer tool conversion. Next up: Stripe's API docs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's your biggest pricing page pet peeve? Drop it in the comments.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>product</category>
      <category>pricing</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building the multi-tenant plumbing every voice-agent tutorial skips</title>
      <dc:creator>mattewens</dc:creator>
      <pubDate>Sat, 08 Aug 2026 12:38:25 +0000</pubDate>
      <link>https://dev.to/mattewens/building-the-multi-tenant-plumbing-every-voice-agent-tutorial-skips-1ff</link>
      <guid>https://dev.to/mattewens/building-the-multi-tenant-plumbing-every-voice-agent-tutorial-skips-1ff</guid>
      <description>&lt;p&gt;Every voice-agent tutorial has the same arc.&lt;/p&gt;

&lt;p&gt;You wire a browser mic into an LLM, add a pleasant synthetic voice, ask it to book an appointment, and declare victory somewhere around minute 37.&lt;/p&gt;

&lt;p&gt;That's fine as a demo. It is not a product.&lt;/p&gt;

&lt;p&gt;The gap shows up the first time you try to sell it to an actual customer. They do not want to open your web app and click a microphone button. They want a phone number. Their customers want to call it. You need to know which tenant owns that number, which assistant should answer, where the transcript goes, how long the call lasted, whether that usage should be billed, and what happens when someone texts STOP because the carrier rules are not optional.&lt;/p&gt;

&lt;p&gt;That is the boring plumbing every tutorial skips.&lt;/p&gt;

&lt;p&gt;And, annoyingly, it is most of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo is easy. The tenant boundary is not.
&lt;/h2&gt;

&lt;p&gt;A single-tenant voice agent can get away with a pile of environment variables:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
bash
TWILIO_NUMBER=+15551234567
VAPI_ASSISTANT_ID=asst_abc123
STRIPE_PRICE_ID=price_xyz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>twilio</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
