Voice Capture

API — read your own transcripts

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

Quick start

  1. In the app: Settings → Connect to Other Apps → Create a key.
  2. In the same place, Copy ready-made URL — you get the address with the key already in it.
  3. Call it:
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.


Authorisation

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.

Scopes

A key may never: create another key, delete the account, download audio.


Reading recordings

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.


Shortcuts: upload a recording

Two “Get contents of URL” actions in a row. The key needs the write scope (tick for Shortcuts in the app).

1. Create the recording

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://…" }

2. Upload the audio

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.


Errors

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

Revoking a key

In the app: Settings → Connect to Other Apps → Revoke key. It stops working within a minute (servers keep a verified key in memory briefly).


Transcribing a recording that never got one

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).


Examples

Python — what arrived since last time

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

Shell — the latest recording as plain text

curl -s -H "Authorization: Bearer $VOICE_CAPTURE_KEY" \
  "$BASE/v2_get_recording?limit=1" | jq -r '.items[0].transcript.text'

Tasks from every set of meeting minutes

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)'

Things to watch out for

A recording doesn't have its transcript straight away. status goes AWAITING_TRANSCRIPTTRANSCRIBINGREADY. 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.