Voice API: add voice typing to your app
Add a dictation button to any website or web-based desktop app with the Vibe Typer Voice API. Setup, token routes for Next.js, Express, SvelteKit and Workers, the element reference, the HTTP API, security and billing.
The Vibe Typer Voice API is a speech-to-text API with a drop-in dictation button. It detects 99 languages automatically, returns clean formatted text, and costs US$0.50 per audio hour. You add a <vibe-voice> element to your page and a small token route to your server. The user presses record, speaks, presses stop, and the text appears.
Quick start
1. Get an API key
Sign in on vibetyper.com/developers and press Get API key. This starts a pay-as-you-go subscription. Add a card at checkout, and Polar's confirmation page shows your key. From then on the key lives in your Polar billing portal, under Benefit Grants, and Get API key opens it.
Save the key as an environment variable on your server:
VIBE_TYPER_API_KEY=VTSK-...
2. Add the token route
Install the package:
npm install @vibetyper/voice
Add a route that trades your secret key for a recording token. requireUser stands for your existing login check, and should respond with a 401 when nobody is signed in.
import { voiceTokenResponse } from '@vibetyper/voice/server';
export async function POST(request) {
const user = await requireUser(request);
return voiceTokenResponse({
apiKey: process.env.VIBE_TYPER_API_KEY,
userId: user.id,
});
}
userId is required. It is your own ID for the signed-in user, 1 to 128 characters. We use it for per-user rate limits and the audit trail, and never send it to Polar.
3. Add the element
<script type="module" src="https://cdn.jsdelivr.net/npm/@vibetyper/voice@0.1/dist/voice.js"></script>
<textarea id="message"></textarea>
<vibe-voice token-url="/api/voice-token" for="message"></vibe-voice>
If you bundle your front end, import '@vibetyper/voice' instead of using the script tag.
Token route by framework
Every version does the same three things. It checks your login, rejects signed-out requests with a 401, and passes our status and body through unchanged. Passing them through matters, because the element reads our error codes from the token response to show subscription_inactive or limit_reached before the user starts speaking.
Next.js
// app/api/voice-token/route.js
import { voiceTokenResponse } from '@vibetyper/voice/server';
import { getCurrentUser } from '@/lib/auth'; // your existing login check
export async function POST() {
const user = await getCurrentUser();
if (!user) return new Response('Unauthorized', { status: 401 });
return voiceTokenResponse({ apiKey: process.env.VIBE_TYPER_API_KEY, userId: user.id });
}
Express
createVoiceToken returns the token, or throws a VoiceTokenError that carries our status and body.
import { createVoiceToken, VoiceTokenError } from '@vibetyper/voice/server';
app.post('/api/voice-token', requireLogin, async (req, res, next) => {
try {
res.json(await createVoiceToken({ apiKey: process.env.VIBE_TYPER_API_KEY, userId: req.user.id }));
} catch (error) {
if (!(error instanceof VoiceTokenError)) return next(error);
res.status(error.status).json(error.body);
}
});
SvelteKit
This assumes your hooks.server.js puts the signed-in user on locals.user.
// src/routes/api/voice-token/+server.js
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { voiceTokenResponse } from '@vibetyper/voice/server';
export async function POST({ locals }) {
if (!locals.user) error(401, 'Unauthorized');
return voiceTokenResponse({ apiKey: env.VIBE_TYPER_API_KEY, userId: locals.user.id });
}
Cloudflare Workers
Store the key with wrangler secret put VIBE_TYPER_API_KEY.
import { voiceTokenResponse } from '@vibetyper/voice/server';
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (request.method !== 'POST' || pathname !== '/api/voice-token') {
return new Response('Not found', { status: 404 });
}
const user = await getUser(request, env); // your existing login check
if (!user) return new Response('Unauthorized', { status: 401 });
return voiceTokenResponse({ apiKey: env.VIBE_TYPER_API_KEY, userId: user.id });
},
};
Both helpers throw straight away if apiKey or userId is empty, so a missing environment variable shows up in development rather than in production.
Element reference
Attributes
| Attribute | Description |
|---|---|
token-url |
Required. Your token route. The element sends a POST to it each time the user presses record. |
for |
Optional. The ID of an input or textarea. Text goes in at its cursor, and the element fires an input event on the field afterwards. |
magic-formatter |
on (default) or off. Read when recording starts, so a change applies to the next recording. |
disabled |
Disables the button. |
The element sets its own state attribute to idle, recording or processing, so you can style it with selectors like vibe-voice[state="recording"].
The button takes the text colour of the page around it and never changes size, so you can place it anywhere. While recording it turns into a red stop button, and a small capsule floats above it with live level bars and a timer. Recording is red by default. Change it with --vibe-voice-accent, and style the rest with ::part(button), ::part(capsule) and ::part(message).
vibe-voice { --vibe-voice-accent: #6d4aff; }
Flow
The user presses the button. The element fetches a token, opens the microphone and records. Pressing again stops it, and the text arrives a moment later. Recording stops by itself at 5 minutes. Escape cancels a recording in progress, but not one already stopped.
Events
| Event | detail |
When |
|---|---|---|
text |
{ text, recordingId } |
A recording finished. text is empty if nobody spoke. |
error |
{ code, message } |
Something stopped the recording. See the codes below. |
Error codes
code |
What happened | What to do |
|---|---|---|
subscription_inactive |
The subscription is past due, unpaid, canceled or set to cancel. | Fix billing in the portal from Get API key on /developers. |
limit_reached |
This billing period's usage reached the monthly limit. | Wait for the next period, or ask us to raise the limit. |
mic_blocked |
The user or the browser blocked the microphone. | Ask the user to allow microphone access. |
unsupported |
The browser can't record here, for example on a page served over plain HTTP. | Serve the page over HTTPS, or hide the button. |
unauthorized |
The secret key is unknown, revoked or rotated, it was used from a browser, or your token route answered 401. | Check the key on your server, and that the user is signed in. |
rate_limited |
Too many requests, or too many recordings running at once. | Let the user try again shortly. |
network |
The request failed, the audio was rejected, or transcription failed. | Let the user try again. |
By default the element shows a short English message beside the button. To show your own copy, in your own language, call preventDefault():
document.querySelector('vibe-voice').addEventListener('error', event => {
if (event.detail.code === 'subscription_inactive' || event.detail.code === 'limit_reached') {
event.preventDefault();
showNotice('Voice input is paused. Ask an admin to check the Vibe Typer billing.');
}
});
TypeScript and React
In React, import '@vibetyper/voice/react' instead. It loads the same element plus a React 19 JSX declaration for <vibe-voice>, so it type-checks in React TypeScript projects. There is no wrapper package. React 19 renders it as a plain custom element.
Rich editors
Rich text editors aren't inputs or textareas, so leave out for and listen for the text event:
import '@vibetyper/voice';
document.querySelector('vibe-voice')
.addEventListener('text', event => editor.insertText(event.detail.text));
In React 19:
import '@vibetyper/voice/react';
export function VoiceButton({ editor }) {
return (
<vibe-voice
token-url="/api/voice-token"
ontext={event => editor.insertText(event.detail.text)}
/>
);
}
Desktop apps
Apps built on web technology work unchanged. The same element runs in Electron, Tauri and similar frameworks.
- Electron, and Tauri on Windows, use Chrome's engine, so they stream audio while the user speaks.
- Tauri on macOS and Linux uploads the recording when the user presses stop.
- You have to allow microphone access. In Electron that means a permission handler, and on macOS you also need the standard microphone usage description.
Native apps (Swift, C#, Qt and so on) call the HTTP API directly. They record 16 kHz PCM, get a recording token from your own server, and POST the audio to the transcription endpoint. You supply your own button. Server-side transcription, below, shows the two calls. CORS doesn't apply to native apps.
Never ship the secret key inside the app. A desktop app runs on your users' machines, so a key inside it can be extracted, just like one in a website. Get recording tokens from your server, behind your app's own login. If your app has no backend at all, a small serverless function can issue tokens.
Server-side transcription
If you already capture audio on a server, make the same two calls the element makes. There is no way to send audio with the secret key directly.
import { createVoiceToken } from '@vibetyper/voice/server';
const { token, transcribe_url } = await createVoiceToken({
apiKey: process.env.VIBE_TYPER_API_KEY,
userId: user.id,
});
const url = new URL(transcribe_url);
url.searchParams.set('sampleRate', '16000');
url.searchParams.set('magicFormatter', '1');
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' },
body: pcm, // 16-bit little-endian, mono, 16 kHz
});
const { text, billable_seconds } = await response.json();
Each token covers one recording, so request a new one for every clip.
HTTP API
The base URL is https://api.vibetyper.com. Every error has the same shape:
{ "error": { "code": "limit_reached", "message": "..." } }
POST /v1/tokens
Call this from your server only.
- Send the secret key as
Authorization: Bearer <key>, and a JSON body of{ "user_id": "..." }.user_idis your ID for the signed-in user, 1 to 128 characters. - It returns
token,recording_id,expires_atandtranscribe_url. - One token covers one recording, and it expires 6 minutes after it's issued.
- It runs the billing check, so an account with an inactive subscription or over its limit fails here, before the user speaks.
POST /v1/transcribe
Call this from the browser element or from your server.
- Send the recording token as
Authorization: Bearer <token>, withContent-Type: application/octet-stream. - The body is raw PCM, 16-bit little-endian, mono, 16 kHz, at most 5 minutes. You can stream it or send it whole.
- Query parameters are
sampleRate=16000, which is required and the only accepted value, andmagicFormatter=1(default) or0. There is nolanguageparameter. The language is always detected automatically. - It returns
text,recording_idandbillable_seconds. A recording with no speech returns empty text and 0 billable seconds, and isn't billed.
Errors
Your token route passes our status and body through, so the element sees the same codes from either call.
| HTTP | code |
Meaning | Element error code |
|---|---|---|---|
| 400 | invalid_request |
Malformed request or audio | network |
| 401 | invalid_key |
Secret key unknown, revoked or rotated | unauthorized |
| 401 | invalid_token |
Recording token bad or expired. The element gets a new token and retries once | network |
| 402 | subscription_inactive |
The subscription is past due, unpaid, canceled or set to cancel. Fix billing in the portal | subscription_inactive |
| 402 | limit_reached |
This billing period's usage has reached the monthly limit | limit_reached |
| 403 | browser_origin |
Secret key used from a browser | unauthorized |
| 409 | recording_used |
Recording already in flight or transcribed. Only the element's retry logic sees this | network |
| 413 | audio_too_long |
Over 5 minutes | network |
| 429 | rate_limited |
A rate limit or the simultaneous-recording cap | rate_limited |
| 500 | internal_error |
Something failed on our side. Try again | network |
| 502 | transcription_failed |
Speech provider failed. Not billed | network |
Retrying a recording is always safe. We transcribe and bill each recording ID at most once, so a retry after a lost response gets recording_used instead of a second charge.
Security
- The secret key stays on your server. Never put it in front-end code, a mobile app or a desktop app.
/v1/tokensrejects any request that carries a browserOriginheader withbrowser_origin, so a key pasted into front-end code fails during development. - Put the token route behind your login. Anyone who can call your token route can record on your account, so check the session and pass the real user ID.
userIdis required for this reason. A signed-in user copying tokens from DevTools gets one recording per token, rate-limited and attributed to their user ID. - Tokens are worth one recording. A token grants transcription only, for one recording, and expires after 6 minutes.
- Rotate a leaked key. Open your billing portal from Get API key on /developers and press Rotate. The old key stops working in Polar straight away, and the change takes up to 5 minutes to reach our API. Until you rotate, a leaked key can cost at most your monthly limit.
- Keep the key out of AI agents. The "Copy prompt for your AI agent" button on /developers never includes your key, and you shouldn't paste one into an agent either. Anything you give an agent can end up in its logs.
Content Security Policy
The microphone worklet is bundled inside the element and loaded from a blob: URL. If your pages send a strict Content Security Policy, whether from your app, your host or a Cloudflare rule, allow blob: in script-src. For example:
script-src 'self' https://cdn.jsdelivr.net blob:
Drop https://cdn.jsdelivr.net if you bundle the package instead of using the script tag. The element also calls https://api.vibetyper.com, so if you restrict connect-src, add it there.
Billing
- Pay as you go. Press Get API key on /developers to subscribe. Audio costs US$0.50 per hour, billed by the second with no minimum and no fixed fee, and Polar invoices each month's usage at the end of the month. Magic Formatter costs nothing extra. Tax is added to the invoice where it applies.
- Monthly limit. Every account has a limit of US$100 a month by default, which is 200 hours of audio. We email you at 90% and at 100%. At the limit, new recordings pause until the next billing period and the element reports
limit_reached. - Raising the limit. Reply to either limit email, or email support@vibetyper.com. We review your payment history and usage, and a raise applies from your next recording.
- Refunds. Usage charges are non-refundable.
- Terms. The Voice API Terms cover billing, what you must tell your users, and how their audio is handled.
- Invoices, card and cancelling. All of these are in your Polar billing portal. If you already pay for Vibe Typer Pro, the API is a separate subscription on the same account. Each subscription keeps the card it was started with.