Self-hostable, provider-agnostic audio transcription microservice.
Chatwoot shipped built-in voice-message transcription via OpenAI in mid-2025. That works great if you're happy sending audio to OpenAI. scribed exists for teams who want the same capability on infrastructure they control, with the freedom to pick a backend: whisper.cpp, faster-whisper, Deepgram, OpenAI, or anything else that lands. One REST API, swappable providers, no lock-in.
cp .env.example .env
docker compose upSubmit a job (placeholder — full API lands in v0.1):
curl -X POST http://localhost:3000/v1/transcriptions \
-H "Authorization: Bearer change-me-dev-key" \
-F "audio=@sample.wav"REST API (Rails 8 API-only) writes jobs to Postgres and enqueues to Sidekiq. Workers stream audio to a pluggable provider sidecar (default: a local whisper.cpp container exposing the OpenAI-compatible /v1/audio/transcriptions endpoint). Results are persisted and optionally POSTed to a webhook signed with an HMAC secret.
client → Rails API → Postgres
↓
Sidekiq → Provider (whisper / Deepgram / OpenAI / …)
→ Webhook
Workers process two queues: transcriptions (compute-bound, concurrency 2) and webhooks (network-bound, concurrency 1). The default queue handles ad-hoc jobs.
scribed ships with four providers out of the box. Configure the default in config/transcription.yml or via the DEFAULT_PROVIDER env var.
| Provider | Backend | Diarization | Streaming | Notes |
|---|---|---|---|---|
openai_compatible |
Any OpenAI-API-shaped endpoint (faster-whisper-server, hwdsl2/docker-whisper, Groq, Together, …) | No | No | Default. Bundled whisper service runs faster-whisper-server on CPU. |
openai |
api.openai.com | No | No | Set OPENAI_API_KEY. 25 MB file limit. |
whisper_cpp |
In-process via whispercpp gem |
No | No | Slowest. No external deps once model is downloaded. Good fallback. |
deepgram |
api.deepgram.com | Yes (native) | Planned (v0.3) | Set DEEPGRAM_API_KEY. |
Adding a provider: subclass Providers::Base, implement #transcribe(audio_path, **opts) returning a Providers::Result, register it in config/transcription.yml.
All requests require Authorization: Bearer <SCRIBED_API_KEY>. Errors come back as {"error":{"code","message"}}.
curl -i -X POST http://localhost:3000/v1/transcriptions \
-H "Authorization: Bearer change-me-dev-key" \
-F "audio=@sample.wav" \
-F "provider=openai_compatible" \
-F "language=en"
# 202 Accepted
# Location: /v1/transcriptions/<uuid>
# { "id": "...", "status": "pending", "created_at": "..." }Or by URL:
curl -i -X POST http://localhost:3000/v1/transcriptions \
-H "Authorization: Bearer change-me-dev-key" \
-H "Content-Type: application/json" \
-d '{"audio_url":"https://example.com/sample.mp3","callback_url":"https://you/hook"}'curl -H "Authorization: Bearer change-me-dev-key" \
http://localhost:3000/v1/transcriptions/<uuid>curl -X DELETE -H "Authorization: Bearer change-me-dev-key" \
http://localhost:3000/v1/transcriptions/<uuid>
# 204 No ContentWhen you POST a transcription with callback_url, scribed will POST the full serialized transcription to that URL when it reaches a terminal state (completed, failed, or cancelled).
Each request includes:
X-Scribed-Event—transcription.completed|transcription.failed|transcription.cancelledX-Scribed-Signature—sha256=<hex>HMAC of the body, computed with the per-record secret (orSCRIBED_WEBHOOK_SECRETfallback)User-Agent: scribed/0.1
Verify the signature server-side before trusting the payload:
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, request.body.read)
ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-Scribed-Signature"])Delivery uses Sidekiq with exponential backoff retries (up to ~5 attempts in v0.1, ~25 in production tuning).
For providers that don't support speaker labels natively (everything except Deepgram), pass diarize: true and provide an audio_url. scribed submits the audio to pyannote.ai asynchronously and waits for a callback. When the callback lands, scribed merges speaker labels into the existing transcription segments and fires the user webhook.
curl -X POST http://localhost:3000/v1/transcriptions \
-H "Authorization: Bearer change-me-dev-key" \
-H "Content-Type: application/json" \
-d '{
"audio_url": "https://example.com/call.mp3",
"provider": "openai_compatible",
"diarize": true,
"callback_url": "https://you/hook"
}'Requirements:
PYANNOTE_API_KEYsetPYANNOTE_WEBHOOK_BASE_URLset to the public URL of your scribed deployment (so pyannote can call back). Use ngrok for local testing.audio_urlis required (pyannote can't fetch ActiveStorage blobs without exposing signed URLs — coming in a future phase).
Native Deepgram diarization (no pyannote round-trip) works synchronously: just pass provider: "deepgram" and diarize: true.
Real-time streaming transcription via ActionCable is planned for v0.3 but not yet implemented. The transport, message protocol, provider mapping, and state model are designed in docs/streaming.md.
Today, subscribing to the TranscriptionChannel returns a not_implemented error event. The stub exists to reserve the route + protocol shape; future v0.3 work fills in the body.
Runnable demos live in examples/:
curl_voice_note.sh— submit a short voice note, poll, print transcriptcurl_call_recording.sh— Deepgram + native diarizationchatwoot_webhook_bridge/— Sinatra app that wires scribed into Chatwoot's webhook + REST API. Lets a Chatwoot deployment use scribed instead of its built-in OpenAI transcription.
See examples/README.md for the full walkthrough.
- v0.1 — synchronous + async transcription, OpenAI-compatible provider, API-key auth, webhooks.
- v0.2 — diarization (pyannote, native Deepgram), word-level timestamps, multiple language hints, retries with backoff. ✅ delivered
- v0.3 — live streaming via ActionCable + Deepgram WebSocket, multi-tenant projects, per-project provider routing, usage metering, admin UI.
MIT — see LICENSE.