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.
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.
Route
Purpose
POST /api/customer
Create Dodo customer + seed 500 free chars to wallet
git clone https://github.com/aainanarang29/chirpify-aicd chirpify-ainpm i
Create a .env file in your project root:
.env
# Dodo PaymentsDODO_PAYMENTS_API_KEY=sk_test_your_test_key_hereDODO_ENV=test_modeDODO_PAYMENTS_WEBHOOK_KEY=whsec_your_webhook_secret_here# ElevenLabsELEVENLABS_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.
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=25 (200K characters). Save all three product_id values. You’ll plug them into your checkout API next.
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.
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 creditsconst 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:
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.
idempotency_key - using paymentId ensures the wallet is only credited once per payment, even if Dodo retries the webhook.
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.
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.
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 APIasync 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.
Dodo’s test mode gives you a fake card that always succeeds:
Field
Value
Card Number
4242 4242 4242 4242
Expiry
Any future date
CVC
Any 3 digits
ZIP
Any 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.
Use payment_id as your idempotency_key - Dodo retries failed webhooks. This prevents double-credits.
Check, generate, then debit - always check wallet balance before calling ElevenLabs. Only debit after a successful generation. Return 402 to trigger the upsell modal.
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.