Voice Capture
With a personal key, Notion, Zapier, your own script or your app can pull your finished transcripts and AI outputs. The key can only read, belongs to one account and you can revoke it any time.
Storage and processing both run in the EU (Frankfurt / Netherlands). Base address for everything:
https://europe-west3-voice-recorder-2026.cloudfunctions.net
curl "https://europe-west3-voice-recorder-2026.cloudfunctions.net/v2_get_recording?limit=5&key=vcr_…"
That ready-made URL behaves like a password: anyone who has it reads your transcripts. Put it in an environment variable, not straight into your code.
The key can only read (unless you give it the write scope): it can't upload, delete, or touch your account. You revoke it in the same place and it stops working within a minute.
Two paths, each for something different:
| for what | how | |
|---|---|---|
| Firebase ID token | the app and the web | Authorization: Bearer <id_token> |
Personal key vcr_… |
Notion, Zapier, Shortcuts, your own scripts | Authorization: Bearer vcr_… or ?key=vcr_… |
You create the key in the app: Settings → Connect to Other Apps. The full key is shown only once — the server keeps just its fingerprint.
The static token from version 1.1.1 doesn't work here. It was a master key to all data of all users; a personal key belongs to one account, has a scope and can be revoked.
recordings:read — read your own recordings (default)recordings:write — additionally create recordings (Shortcuts)A key may never: create another key, delete the account, download audio.
GET /v2_get_recording?limit=20&key=vcr_…
GET /v2_get_recording?id=<recording_id>&key=vcr_…
Parameters: limit 1–100 (default 20) and two cursors depending on which way
you're moving. since=<ISO8601> returns newer records ascending (“what
has arrived since last time”), before=<ISO8601> returns older ones
descending (loading history). Only one at a time; since wins.
List response:
{
"items": [{
"id": "…", "title": "Site inspection",
"status": "READY", "created_at": "…Z", "recorded_at": "…Z",
"duration": 92.5, "device": "iPhone", "location": {…}, "bookmarks": [],
"no_speech": false, // true = no speech was found in the audio
"transcript": {
"provider": "apple", // or "gemini"
"language": "cs-CZ",
"text": "…", // verbatim transcript, immutable
"segments": [{"start": 0.0, "text": "…"}],
"words": [{"s": 0.0, "t": "So"}]
},
"outputs": [{ // AI outputs from templates
"template_id": "zapis-z-jednani", "template_name": "Meeting minutes",
"title": "…", "source_type": "transcript",
"sections": { "summary": "…", "tasks": [{"text": "…", "owner": "…"}] }
}]
}],
"count": 1,
"next_since": "…Z", // cursor for reading forward
"next_before": "…Z" // cursor to the older page (absent with `since`)
}
Only a whitelist of fields goes out — internal storage paths, the owner and processing costs never appear in the response.
sections take their shape from the template: text, a list of strings, tasks
(text/owner/deadline), speakers (label/description), a timeline
(time/event) or measured values (label/value/estimated). For values,
estimated separates what the user said from what AI worked out — in a
health journal that's the distinction that matters.
Two “Get contents of URL” actions in a row. The key needs the write scope (tick for Shortcuts in the app).
POST /v2_create_recording
Authorization: Bearer vcr_…
Content-Type: application/json
{ "duration": 23.4,
"filename": "shortcut.m4a",
"title": "From a Shortcut", // optional
"needs_transcription": true, // AI will transcribe it
"want_audio_upload": true }
Response: { "recording_id": "…", "status": "AWAITING_TRANSCRIPT", "upload_url": "https://…" }
PUT <upload_url>
Content-Type: audio/m4a
<binary file contents>
No Authorization header — the signature in the address carries it and is valid for 15 minutes. The rest happens on its own: Gemini transcribes the audio and, depending on the settings, an AI output may be created too.
If you already have a transcript (from dictation, say), send it instead of
needs_transcription:
{ "duration": 23.4,
"transcript": { "text": "…", "language": "en-US" } }
The recording is then created ready and nothing gets transcribed.
| code | meaning |
|---|---|
| 401 | the token or key is missing or invalid |
| 403 | the key lacks the required scope (typically write) |
| 402 | out of minutes ({"error": "NO_CREDIT", "kind": "audio"}) |
| 404 | the record doesn't exist, or isn't yours — both look the same |
| 409 | the transcript or audio needed for that output is missing |
In the app: Settings → Connect to Other Apps → Revoke key. It stops working within a minute (servers keep a verified key in memory briefly).
POST /v2_reprocess_recording — ID token only (not an API key).
{ "recording_id": "abc123" }
For a recording stuck in NO_CREDIT (out of minutes) or FAILED. The server
doesn't retry on its own: it waits until the user tops up credit and asks
for it. The endpoint only sets the document back to AWAITING_TRANSCRIPT and
rewrites the object in storage onto itself — transcription then runs the usual
way through the storage trigger, so the result is no different from the first
upload.
Extra responses: 409 ALREADY_TRANSCRIBED (the recording has a transcript
— the original is immutable), 409 NOT_REPROCESSABLE (a state other than
NO_CREDIT/FAILED), 409 NO_AUDIO (no audio in storage), 402 NO_CREDIT
(still out of minutes).
Store the next_since cursor and send it back next time; the server returns
only newer records, so you never read the same one twice.
import os, json, pathlib, requests
BASE = "https://europe-west3-voice-recorder-2026.cloudfunctions.net"
KEY = os.environ["VOICE_CAPTURE_KEY"] # vcr_…
STATE = pathlib.Path("~/.voice-capture-cursor").expanduser()
params = {"limit": 50}
if STATE.exists():
params["since"] = STATE.read_text().strip()
r = requests.get(f"{BASE}/v2_get_recording", params=params,
headers={"Authorization": f"Bearer {KEY}"}, timeout=30)
r.raise_for_status()
data = r.json()
for item in data["items"]:
print(item["recorded_at"], "—", item.get("title") or "(untitled)")
transcript = (item.get("transcript") or {}).get("text", "")
if transcript:
print(transcript[:200])
if data.get("next_since"):
STATE.write_text(data["next_since"]) # only after successful processing
curl -s -H "Authorization: Bearer $VOICE_CAPTURE_KEY" \
"$BASE/v2_get_recording?limit=1" | jq -r '.items[0].transcript.text'
sections take their shape from the template — tasks are an array of objects,
not strings.
curl -s -H "Authorization: Bearer $VOICE_CAPTURE_KEY" \
"$BASE/v2_get_recording?limit=50" \
| jq -r '.items[].outputs[]?
| select(.template_id == "zapis-z-jednani")
| .sections.tasks[]?
| "- \(.text)" + (if .owner then " (\(.owner))" else "" end)'
A recording doesn't have its transcript straight away. status goes
AWAITING_TRANSCRIPT → TRANSCRIBING → READY. The server has no filter by
state — filter the response yourself on the status field and pick up the
unfinished ones on your next pass.
A recording can arrive late. One made offline is uploaded once the phone is
online, so it shows up in the list with an earlier recorded_at. That's why
the cursor follows created_at rather than recording time — and why it pays
to deduplicate by id.
An empty transcript is a valid result. When there was no speech in the
recording, text is empty and no_speech is true. It isn't an error; the
AI used to invent content in that situation, now it returns nothing instead.
There's no hard rate limit yet, but read in batches (limit up to 100)
rather than looping one record at a time. The server records when a key was
last used, so the app shows that something is reading — and when a limit does
arrive, it will be based on real traffic.