Skip to main content

Chirpify AI: Text-to-Speech App

By the end of this cookbook, you’ll have a fully deployed text-to-speech app that lets users type text, pick a voice, generate speech, and pay for it with a freemium model, three credit tiers, server-side wallet tracking, and a real checkout flow powered by Dodo Payments.

Full Source Code

Skip the tutorial. Clone, configure, deploy. Every file referenced below lives here.

What You’ll Build

  • ElevenLabs TTS with 5 voice options: type text, hear it spoken back
  • Freemium model with 500 free characters on first visit, no signup needed
  • Three credit packs: Starter (10K), Pro (50K), Power (200K) characters
  • Dodo Payments checkout with real payment links, receipts, and test mode for dev
  • Server-side credit tracking with Dodo Wallets, tamper-proof, auditable, no database needed
  • Webhook-powered fulfillment credits added to wallet only after confirmed payment
  • One-command deploy with vercel --prod
Total cost to build and host: $0.

Architecture

Your frontend talks to Vercel serverless functions that use the Dodo Payments SDK to create customers with wallets, call ElevenLabs for audio generation (checking and debiting the wallet server-side), and create checkout sessions for purchases. When a payment succeeds, a webhook credits the customer’s wallet automatically. No database needed Dodo Wallets are the source of truth.
Credit Purchase Flow
Speech Generation Flow
RoutePurpose
POST /api/customerCreate Dodo customer + seed 500 free chars to wallet
GET /api/balanceRead wallet balance from Dodo
POST /api/checkoutCreate checkout session linked to customer
POST /api/speakCheck wallet, call ElevenLabs, debit wallet
POST /api/webhookReceive payment.succeeded, credit wallet

Pricing Model

Chirpify uses the same model as ElevenLabs, Resend, and most usage-based SaaS: buy credits upfront, spend them as you go.
PackCharactersPricePer 1K charsWho it’s for
Starter10,000$5$0.50Kicking the tires
Pro50,000$10$0.20Regular use
Power200,000$25$0.125Volume users
The more you buy, the cheaper it gets. A straightforward volume discount where users self-select into the tier that matches their usage.

Project Setup

git clone https://github.com/aainanarang29/chirpify-ai
cd chirpify-ai
npm i
Create a .env file in your project root:
.env
# Dodo Payments
DODO_PAYMENTS_API_KEY=sk_test_your_test_key_here
DODO_ENV=test_mode
DODO_PAYMENTS_WEBHOOK_KEY=whsec_your_webhook_secret_here

# ElevenLabs
ELEVENLABS_KEY=xi_your_elevenlabs_key_here

# App URL (update for production)
SITE_URL=http://localhost:3000
Never commit .env to version control. The .gitignore in the repo already handles this.
Run locally:
npm run dev
The dev script uses Node’s built-in --env-file flag to load your .env automatically. Requires Node 20.6+ (run node -v to check). Open http://localhost:3000 to see it running.

Step 1: Set Up Products in Dodo

Before writing any code, you need three products in Dodo - one for each credit pack. Two ways to do this.

Option A: The Dashboard (2 minutes, no code)

1

Grab your API key

Head to your Dodo Payments Dashboard, go to API Keys, and generate a new key. Save it somewhere safe. You’ll need it in Step 2.
2

Create the Starter Pack

Products → Create Product. Name it “Starter Pack”, set the price to $5.00 USD, type to one-time payment, tax category to Digital Products. Hit Create, copy the product_id. Done.
3

Repeat for Pro and Power

Same flow. Pro Pack = 10(50Kcharacters).PowerPack=10 (50K characters). Power Pack = 25 (200K characters). Save all three product_id values. You’ll plug them into your checkout API next.

Option B: Ask your agent to cURL it

If clicking buttons isn’t your thing:
curl -X POST "https://test.dodopayments.com/products" \
  -H "Authorization: Bearer YOUR_DODO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Starter Pack",
    "description": "10,000 characters for text-to-speech",
    "price": {
      "currency": "USD",
      "discount": 0,
      "price": 500,
      "purchasing_power_parity": false,
      "type": "one_time_price"
    },
    "tax_category": "digital_products"
  }'

All three cURL commands

Starter, Pro, and Power pack creation. Copy, paste, run.
test.dodopayments.com for development. live.dodopayments.com when you’re ready for real money.

Step 2: Create Customers and Build the Checkout API

Every user needs a Dodo customer account with a wallet. On first visit, your app creates one and seeds it with 500 free characters. When they click “Buy”, a checkout session links to that customer so the webhook knows whose wallet to credit. Every API call below uses the official dodopayments SDK - no raw fetch needed.

Create a Customer (on first visit)

api/customer.js
import DodoPayments from 'dodopayments';

const dodo = new DodoPayments({
  environment: process.env.DODO_ENV || 'test_mode',
});

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    // Create a Dodo customer
    const customer = await dodo.customers.create({
      email: `user_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@chirpify.ai`,
      name: 'Chirpify User',
    });

    // Credit 500 free characters as a welcome bonus
    const wallet = await dodo.customers.wallets.ledgerEntries.create(
      customer.customer_id,
      {
        amount: 500,
        currency: 'USD',
        entry_type: 'credit',
        idempotency_key: `welcome_${customer.customer_id}`,
        reason: 'Welcome bonus: 500 free characters',
      }
    );

    res.json({ customerId: customer.customer_id, balance: wallet.balance });
  } catch (err) {
    console.error('Customer creation error:', err.message);
    res.status(500).json({ error: 'Failed to create customer' });
  }
}
The customer_id gets stored in localStorage but only as an identifier. The actual credits live in Dodo’s wallet, not the browser.

Checkout Implementation

api/checkout.js
import DodoPayments from 'dodopayments';

// Character credit packs for Chirpify AI
const PRODUCTS = {
  starter: { id: 'pdt_YOUR_STARTER_ID', characters: 10000, price: 5, name: 'Starter Pack' },
  pro:     { id: 'pdt_YOUR_PRO_ID',     characters: 50000, price: 10, name: 'Pro Pack' },
  power:   { id: 'pdt_YOUR_POWER_ID',   characters: 200000, price: 25, name: 'Power Pack' },
};

const dodo = new DodoPayments({
  environment: process.env.DODO_ENV || 'test_mode',
});

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const { pack, customerId } = req.body;

  if (!pack || !PRODUCTS[pack]) {
    return res.status(400).json({ error: 'Invalid pack selected' });
  }
  if (!customerId) {
    return res.status(400).json({ error: 'customerId is required' });
  }

  const product = PRODUCTS[pack];
  const returnUrl = process.env.SITE_URL || 'https://chirpify-ai.vercel.app';

  try {
    const payment = await dodo.payments.create({
      payment_link: true,
      billing: { country: 'US' },
      customer: { customer_id: customerId },
      product_cart: [{ product_id: product.id, quantity: 1 }],
      return_url: `${returnUrl}?success=true`,
    });

    res.json({
      checkoutUrl: payment.payment_link,
      characters: product.characters,
      productName: product.name,
    });
  } catch (err) {
    console.error('Checkout error:', err.message);
    res.status(500).json({ error: 'Failed to create checkout session' });
  }
}
Three things to note:
  1. customer: { customer_id: customerId } links the payment to an existing Dodo customer. This is what connects the payment to the right wallet.
  2. No characters in the return URL - credits are added server-side via webhook, not client-side via URL params.
  3. payment_link: true - always include this flag. Without it, payment_link comes back null and the checkout button won’t work.
Always include payment_link: true in your request body. This is the most common integration mistake.

Step 3: Handle Payment Success with Webhooks

After checkout, Dodo fires a payment.succeeded webhook to your server. Your handler verifies the signature, maps the purchased product to a character count, and credits the customer’s wallet. No URL params, no localStorage - the server is the source of truth. Dodo webhooks use the Standard Webhooks format (Svix). Each request includes three headers: webhook-id, webhook-timestamp, and webhook-signature. You must verify the signature before trusting the payload - otherwise anyone can POST fake events to your endpoint and credit themselves unlimited characters.
api/webhook.js
import crypto from 'crypto';
import DodoPayments from 'dodopayments';

const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY;

const dodo = new DodoPayments({
  environment: process.env.DODO_ENV || 'test_mode',
});

// Map product IDs to character credits
const PRODUCT_CREDITS = {
  'pdt_YOUR_STARTER_ID': 10000,   // Starter Pack
  'pdt_YOUR_PRO_ID':     50000,   // Pro Pack
  'pdt_YOUR_POWER_ID':   200000,  // Power Pack
};

// Verify Dodo webhook signature (Standard Webhooks / Svix format)
function verifyWebhookSignature(body, headers) {
  const webhookId = headers['webhook-id'];
  const webhookTimestamp = headers['webhook-timestamp'];
  const webhookSignature = headers['webhook-signature'];

  if (!webhookId || !webhookTimestamp || !webhookSignature) {
    throw new Error('Missing webhook signature headers');
  }

  // Reject webhooks older than 5 minutes to prevent replay attacks
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(webhookTimestamp)) > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // The signed content is: "{webhook-id}.{webhook-timestamp}.{body}"
  const signedContent = `${webhookId}.${webhookTimestamp}.${typeof body === 'string' ? body : JSON.stringify(body)}`;

  // Decode the secret (strip the "whsec_" prefix, then base64-decode)
  const secretBytes = Buffer.from(DODO_WEBHOOK_KEY.replace('whsec_', ''), 'base64');

  // Compute the expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  // The header can contain multiple signatures (e.g., "v1,abc123 v1,def456")
  const passedSignatures = webhookSignature.split(' ').map(sig => sig.split(',')[1]);

  if (!passedSignatures.some(sig => sig === expectedSignature)) {
    throw new Error('Invalid webhook signature');
  }
}

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  // Step 1: Verify the webhook signature
  try {
    verifyWebhookSignature(req.body, req.headers);
  } catch (err) {
    console.error('Webhook verification failed:', err.message);
    return res.status(401).json({ error: 'Invalid webhook signature' });
  }

  const event = req.body;
  const eventType = req.headers['webhook-event-type'];

  // Only handle successful payments
  if (eventType !== 'payment.succeeded') {
    return res.json({ received: true });
  }

  const customerId = event.customer?.customer_id;
  const paymentId = event.payment_id;
  const productId = (event.product_cart || [])[0]?.product_id;

  if (!customerId || !productId) {
    return res.status(400).json({ error: 'Missing customer or product info' });
  }

  const characters = PRODUCT_CREDITS[productId];
  if (!characters) {
    return res.status(400).json({ error: 'Unknown product' });
  }

  try {
    // Step 2: Credit the customer's wallet
    const wallet = await dodo.customers.wallets.ledgerEntries.create(
      customerId,
      {
        amount: characters,
        currency: 'USD',
        entry_type: 'credit',
        idempotency_key: paymentId, // Prevents double-credit on retry
        reason: `Credit pack purchase: ${characters.toLocaleString()} characters`,
      }
    );

    console.log(`Credited ${characters} chars to ${customerId}. New balance: ${wallet.balance}`);
    res.json({ received: true, credited: characters, balance: wallet.balance });
  } catch (err) {
    console.error('Webhook processing error:', err.message);
    res.status(500).json({ error: 'Webhook processing failed' });
  }
}
Two things keep this handler secure:
  1. Signature verification - the verifyWebhookSignature function rejects any request that wasn’t signed by Dodo. Without this, anyone could POST a fake payment.succeeded event and credit themselves unlimited characters.
  2. idempotency_key - using paymentId ensures the wallet is only credited once per payment, even if Dodo retries the webhook.

Configure Webhooks

In the Dodo Dashboard, go to Webhooks and create one:
  • URL: https://your-app.vercel.app/api/webhook
  • Events: payment.succeeded

Step 4: Track Usage and Deduct Credits (Server-Side)

Every TTS request goes through the server, which checks the Dodo wallet balance before calling ElevenLabs and debits only after a successful generation. Users can’t manipulate their balance. Dodo’s wallet is the single source of truth.
api/speak.js
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import DodoPayments from 'dodopayments';

const VOICES = {
  george: { id: "JBFqnCBsd6RMkjVDRZzb", name: "George", desc: "Warm British" },
  aria:   { id: "9BWtsMINqrJLrRacOk9x", name: "Aria", desc: "Expressive American" },
  roger:  { id: "CwhRBWXzGAHq8TQ4Fs17", name: "Roger", desc: "Confident American" },
  sarah:  { id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", desc: "Soft American" },
  charlie:{ id: "IKne3meq5aSn9XLyUdCD", name: "Charlie", desc: "Casual Australian" },
};

const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_KEY });

const dodo = new DodoPayments({
  environment: process.env.DODO_ENV || 'test_mode',
});

export default async function handler(req, res) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const { text, voice, customerId } = req.body;

  if (!text || text.trim().length === 0) {
    return res.status(400).json({ error: "No text provided" });
  }
  if (text.length > 500) {
    return res.status(400).json({ error: "Text too long. Max 500 characters." });
  }
  if (!customerId) {
    return res.status(400).json({ error: "customerId is required" });
  }

  const voiceId = VOICES[voice]?.id || VOICES.george.id;
  const charCost = text.length;

  try {
    // 1. Check wallet balance
    const walletData = await dodo.customers.wallets.list(customerId);
    const wallet = walletData.items?.find(w => w.currency === 'USD');
    const balance = wallet?.balance || 0;

    if (balance < charCost) {
      return res.status(402).json({
        error: `Insufficient credits. Need ${charCost}, have ${balance}.`,
        balance,
      });
    }

    // 2. Generate speech via ElevenLabs
    const audio = await client.textToSpeech.convert(voiceId, {
      text, model_id: "eleven_multilingual_v2"
    });

    const chunks = [];
    for await (const chunk of audio) {
      chunks.push(chunk);
    }
    const buffer = Buffer.concat(chunks);

    // 3. Debit wallet ONLY after successful generation
    const debitResult = await dodo.customers.wallets.ledgerEntries.create(
      customerId,
      {
        amount: charCost,
        currency: 'USD',
        entry_type: 'debit',
        idempotency_key: `tts_${customerId}_${Date.now()}`,
        reason: `TTS generation: ${charCost} characters`,
      }
    );

    res.json({
      audio: buffer.toString("base64"),
      characters: charCost,
      voice: VOICES[voice]?.name || "George",
      balance: debitResult.balance,
    });
  } catch (err) {
    console.error("Speak error:", err.message);
    if (err.message.includes('Insufficient')) {
      return res.status(402).json({ error: err.message });
    }
    res.status(500).json({ error: "Speech generation failed. Try again." });
  }
}
The pattern: check → generate → debit. Never charge for a failed generation. The 402 status code tells the frontend to show the pricing modal.

Full speak handler

The complete api/speak.js with wallet integration and ElevenLabs SDK.

Step 5: Build the Pricing UI

When credits run out, show a clean upgrade modal. Three cards, three price points, one click to checkout. Each card carries a data-pack attribute that maps to your product config. Click fires /api/checkout with the customerId, response comes back with a Dodo checkout URL, window.location.href does the rest. Click, pay, done.
// Handle pricing card clicks
document.querySelectorAll('.pricing-card').forEach(card => {
  card.addEventListener('click', async () => {
    const pack = card.dataset.pack;
    const customerId = localStorage.getItem('chirpify_customer_id');

    card.style.opacity = '0.7';
    card.style.pointerEvents = 'none';

    try {
      const res = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ pack, customerId }),
      });

      const data = await res.json();
      if (!res.ok) throw new Error(data.error || 'Checkout failed');

      // Redirect to Dodo Payments checkout
      window.location.href = data.checkoutUrl;
    } catch (err) {
      showError(err.message);
      card.style.opacity = '1';
      card.style.pointerEvents = 'auto';
    }
  });
});

Pricing modal markup + handlers

HTML structure, click handlers, and loading states. Ready to drop into your project.

Step 6: Balance API

The endpoint that returns the customer’s current wallet balance. Called on page load and after returning from checkout.
api/balance.js
import DodoPayments from 'dodopayments';

const dodo = new DodoPayments({
  environment: process.env.DODO_ENV || 'test_mode',
});

export default async function handler(req, res) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const customerId = req.query.customer_id;
  if (!customerId) {
    return res.status(400).json({ error: 'customer_id is required' });
  }

  try {
    const walletData = await dodo.customers.wallets.list(customerId);
    const wallet = walletData.items?.find(w => w.currency === 'USD');

    res.json({
      balance: wallet?.balance || 0,
      customerId,
    });
  } catch (err) {
    console.error('Balance check error:', err.message);
    res.status(500).json({ error: 'Failed to fetch balance' });
  }
}

Step 7: Display Credit Balance

A live counter in the header that shows the balance from Dodo’s wallet. Green when healthy, yellow when running low, red when empty. No ambiguity.
// Fetch balance from Dodo wallet via your API
async function fetchBalance() {
  const customerId = localStorage.getItem('chirpify_customer_id');
  const res = await fetch(`/api/balance?customer_id=${customerId}`);
  const data = await res.json();
  credits = data.balance;
  updateCreditsDisplay();
}

function updateCreditsDisplay() {
  const display = document.querySelector('.credits-number');
  const container = document.getElementById('creditsCount');

  display.textContent = credits >= 1000
    ? (credits / 1000).toFixed(credits % 1000 === 0 ? 0 : 1) + 'K'
    : credits.toString();

  container.classList.remove('low', 'empty');
  if (credits <= 0) container.classList.add('empty');
  else if (credits < 100) container.classList.add('low');
}
The balance comes from /api/balance (Step 6), which reads the Dodo wallet. After each TTS generation, the /api/speak response includes the updated balance so the UI stays in sync without an extra API call.

Deploy It

Environment Variables

Add these in Vercel under Settings > Environment Variables:
VariableWhat it doesExample
DODO_PAYMENTS_API_KEYAuthenticates with Dodosk_test_...
DODO_ENVSDK environment (test_mode/live_mode)test_mode
DODO_PAYMENTS_WEBHOOK_KEYVerifies webhook signatures from Dodowhsec_...
ELEVENLABS_KEYAuthenticates with ElevenLabsxi_...
SITE_URLYour app URL (for return redirects)https://chirpify-ai.vercel.app

Deploy

npm install -g vercel
vercel --prod
Two commands and you’re live.

Test It

Dodo’s test mode gives you a fake card that always succeeds:
FieldValue
Card Number4242 4242 4242 4242
ExpiryAny future date
CVCAny 3 digits
ZIPAny 5 digits
Full test flow: Load the app. Get 500 free characters in your Dodo wallet. Generate speech until credits run out. See the pricing modal. Buy the Starter Pack with the test card. Webhook credits your wallet with 10K characters. Generate more speech. Check the Dodo dashboard to see wallet ledger entries (credits, debits, balances). If the wallet ledger shows every transaction and the balance matches, you’ve got a working SaaS.

Things That’ll Save You Debugging Time

  1. Always include payment_link: true - without it, payment_link comes back null and the checkout button won’t work. The most common Dodo integration bug.
  2. Dodo Wallets = server-side credits - tamper-proof, auditable credit tracking. No database needed.
  3. Use payment_id as your idempotency_key - Dodo retries failed webhooks. This prevents double-credits.
  4. Check, generate, then debit - always check wallet balance before calling ElevenLabs. Only debit after a successful generation. Return 402 to trigger the upsell modal.

Going Live

Everything in this cookbook runs on test mode. Flip these switches before taking real payments.
TestLive
Base URLtest.dodopayments.comlive.dodopayments.com
API Keyssk_test_...sk_live_...
PaymentsSimulated (no real charges)Real money
Dashboarddodopayments.com (test mode)dodopayments.com (live mode)
The switch: create live products in the dashboard, generate a live API key, set DODO_ENV=live_mode in Vercel, swap the product IDs in api/checkout.js and api/webhook.js, update the webhook URL to your production domain. Test with a real card (you can refund yourself), and you’re in business.

Keep Building

Dodo Payments Docs

API reference, webhooks, SDKs

ElevenLabs Docs

Voice models, streaming, cloning

Vercel Docs

Edge functions, domains, CI/CD
Want your AI agent to build this for you? Give it this URL and ask it to follow the guide:
https://aainanarang2911gmailcom.mintlify.app/chirpify-cookbook