# Blumify for agents

Blumify turns a link into a timestamped transcript, a summary, notes and a public web page. This file is for
scripts and AI agents. Reading is open to everyone and needs no sign-in, and so do a few new transcriptions a day.
Questions and new translations are model calls: they need a signed-in account and are paid with its AI credits (new
accounts get 30 free). Scripts that do more use a personal API token (see API tokens below); AI apps sign in through
the MCP server (see MCP below).

- API base: https://blumify.io/api/media/v1
- Transcript pages: https://blumify.io/t/{platform}/{id}/{slug} (full transcript: the same URL plus /transcript)
- This file: https://blumify.io/AGENTS.md · site summary: https://blumify.io/llms.txt

## What works

- YouTube videos. The video's own captions are used when it has them; otherwise the audio is transcribed.
- Spotify podcast episodes whose show also publishes a public RSS feed. Spotify-only shows cannot be fetched.
- Apple Podcasts episodes (a link with `?i=<episode id>`).
- Direct links to an audio or video file over http or https.
- Up to 90 minutes of audio. YouTube videos with captions can run up to 4 hours.
- Not supported here: playlists, channels, whole shows or feeds, live streams, and anything private or behind a login.
  YouTube playlists (up to 200 videos) are paid work in the private workspace; see MCP below.
- No speaker labels on free transcripts (labels are paid work in the private workspace). Transcripts, summaries
  and notes are in the spoken language. `/translate` puts the title
  and notes (not the transcript) into one of 20 other languages.
- Transcripts are machine-made (captions, often auto-generated, or Whisper speech-to-text) and can mishear names and
  numbers. Summaries, notes, sections and answers are written by an AI model from the transcript.

## Keys

A transcript's key is `{platform}:{id}`: `youtube:<video id>`, `spotify:<episode id>`, `apple:<episode id>`, or
`web:<24 hex characters>` for a file link. Every URL shape of the same video or episode gives the same key, so a
link that anyone has transcribed before comes back at once.

## Endpoints

### GET /check — is a link already transcribed?

```
GET https://blumify.io/api/media/v1/check?url=https://youtu.be/dQw4w9WgXcQ
GET https://blumify.io/api/media/v1/check?platform=youtube&id=dQw4w9WgXcQ
```

```json
{"cached": true, "key": "youtube:dQw4w9WgXcQ", "permalink": "/t/youtube/dQw4w9WgXcQ/<slug>"}
```

`permalink` is a path on https://blumify.io, or `null` when `cached` is false. Checking first is optional:
`/transcribe` on a cached link also answers at once and costs nothing.

### GET /transcribe — transcribe a link, streamed as Server-Sent Events

```
GET https://blumify.io/api/media/v1/transcribe?url=<link>&summarize=1
```

The response is `text/event-stream`. Events, in order:

- `event: meta`, once: `{"key", "platform", "external_id", "job_id", "cached"}`. `job_id` is null when the
  transcript already exists.
- `event: stage`, each time progress changes: `{"stage", "progress"}`. Stages: `queued`, `dispatching`,
  `resolving`, `fetching_captions` (YouTube), `fetching_audio`, `transcribing` (with `progress` 0–99, a
  percentage), `summarizing`, and `retry_wait` while a temporary failure is retried. `progress` is null except
  while transcribing.
- `: ping`, a comment line, every 15 seconds while nothing changes.
- Then exactly one of `event: done` or `event: error`, and the stream ends.

If someone is already transcribing the same link, you join that job instead of starting another. The stream
closes after 30 minutes with the error `timeout`; the job carries on, and calling again joins it.

`summarize=1` is the default. `summarize=0` leaves the summary fields out of `done`; the summary is still
written for the page.

`done` carries everything:

```json
{
  "key": "youtube:dQw4w9WgXcQ",
  "permalink": "https://blumify.io/t/youtube/dQw4w9WgXcQ/<slug>",
  "metadata": {"platform": "youtube", "external_id": "dQw4w9WgXcQ", "slug": "<slug>", "source_url": "...",
               "title": "...", "creator": "...", "duration_seconds": 212.0, "thumbnail_url": "...",
               "published_at": "...", "word_count": 480, "created_at": "2026-09-27T05:31:21+00:00"},
  "language": "en",
  "source": "captions",
  "summary": "...",
  "key_points": ["..."],
  "chapters": [{"start": 0, "title": "..."}],
  "notes_md": "### ...",
  "sections": [{"start": 0, "title": "...", "question": "...", "answer": "...", "takeaways": ["..."]}],
  "chat_pills": ["Suggested question?"],
  "segments": [{"start": 0.0, "end": 4.2, "text": "..."}]
}
```

Times are seconds from the start. `source` is `captions` or `audio`. If the summary step failed, the summary
fields are empty and the transcript is still complete. Transcripts made before `sections` and `chat_pills`
existed have them as empty lists.

`error` is `{"code", "message"}`; `message` is written for people. Codes: `unsupported_url`, `unavailable`
(private, deleted or could not be found), `auth_required`, `too_long`, `spotify_exclusive`, `spotify_match_failed`,
`metadata_failed`, `transcription_failed`, `no_audio`, `no_speech`, `unreadable`, `busy` (today's capacity
is used up), `timeout`, and rarely `not_found`, `cancelled` or `expired`. `metadata_failed` and
`transcription_failed` are usually worth one retry a few minutes later.

A request that is refused outright gets an HTTP error before any stream starts (see Errors).

### GET /transcripts/{key} — read a finished transcript

```
GET https://blumify.io/api/media/v1/transcripts/youtube:dQw4w9WgXcQ
GET https://blumify.io/api/media/v1/transcripts/youtube:dQw4w9WgXcQ?segments=true
GET https://blumify.io/api/media/v1/transcripts/youtube:dQw4w9WgXcQ/download?format=md
```

JSON with `key`, `platform`, `external_id`, `slug`, `source_url`, `title`, `creator`, `duration_seconds`,
`thumbnail_url`, `published_at`, `language`, `source`, `summary`, `key_points`, `chapters`, `notes_md`,
`sections`, `chat_pills`, `word_count` and `created_at`; `segments=true` adds `segments`. `/download`
returns a file: `format` is `txt`, `srt`, `vtt` or `md` (title, summary, key points, notes, then the
transcript with [mm:ss] markers). A missing key is a 404 with `{"detail": "not found"}`.

### GET /feed — recent transcripts, newest first

```
GET https://blumify.io/api/media/v1/feed?limit=20&offset=0
```

```json
{"items": [{"key": "...", "platform": "...", "external_id": "...", "slug": "...", "title": "...", "creator": "...",
            "thumbnail_url": "...", "duration_seconds": 2051.0, "summary": "...", "created_at": "...",
            "permalink": "/t/..."}],
 "limit": 20, "offset": 0, "next_offset": 20}
```

`limit` is 1–50 (default 20) and `offset` 0–5000; values outside are clamped. `next_offset` is null on the
last page.

### POST /transcripts/{key}/ask — ask a transcript a question, streamed

**Sign-in and credits.** A question needs a signed-in Blumify account (the website's session) and costs AI
credits: a few per started hour of the recording (`GET /transcripts/{key}/prices` gives the number). Without a
sign-in the answer is 401 `signed_out`; without enough credits, 402 `insufficient_credits`, and nothing is
charged. A script or agent usually does not need this endpoint: read the whole transcript for free (`/transcribe`
on a transcribed link, or the MCP `get_transcript` tool) and answer from it yourself.

```
POST https://blumify.io/api/media/v1/transcripts/youtube:dQw4w9WgXcQ/ask
Content-Type: application/json

{"question": "What are the main points?",
 "thread": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "...", "sig": "..."}]}
```

- `question`: required, 1–500 characters, and about this recording (see Scope below).
- `thread`: optional, the earlier questions and answers, oldest first, each with `role` `user` or
  `assistant`. Each assistant turn needs the `sig` from the `done` event that ended it, with its
  question and answer sent back exactly as they were. A turn without a valid `sig` is ignored, together with
  its question: you cannot write the assistant's side of the conversation. Only the last 10 turns are used.

The response is `text/event-stream` with data-only events, plus `: ping` comments while the model works:

```
data: {"type": "token", "text": "The guest says "}
data: {"type": "token", "text": "sleep comes first [04:45]."}
data: {"type": "done", "sig": "3f9c...", "credits": 2}
```

`credits` is what the answer cost. Instead of `done` there can be `data: {"type": "error", "message": "..."}`;
an answer that fails costs nothing. The model is given the transcript alone and
told to answer only from it, to say when it does not cover the question, and to cite moments as `[mm:ss]` or
`[h:mm:ss]`, the transcript's own markers. Answers stay under about 200 words. Check the cited moment before
relying on an answer.

**Scope.** Ask answers questions about the recording only: what is said, who says it, when, why and what it means,
including explaining, summarising or simply rewording parts of it. Anything else gets one fixed sentence instead of
an answer: writing code, essays, posts or plans, general knowledge, advice, maths, translating other text,
role-play, and questions about the assistant or its instructions. The model still read the transcript, so that
answer is charged like any other.

### GET /transcripts/{key}/prices — what a question and a translation cost

```json
{"paid": true, "ask": 2, "translate": 1, "welcome": 30}
```

`ask` is the credits for one question about this transcript, `translate` for one new translation, and
`welcome` the free credits a new account gets. `paid` false means questions and translations are free for now
(counted per IP, as before).

### POST /transcripts/{key}/translate — the title and notes in another language

```
POST https://blumify.io/api/media/v1/transcripts/youtube:nI_owrxLoOQ/translate
Content-Type: application/json

{"to": "en"}
```

`to` is one of `en`, `es`, `pt`, `fr`, `de`, `it`, `nl`, `pl`, `tr`, `ru`, `uk`, `ar`, `hi`,
`bn`, `id`, `vi`, `th`, `ja`, `ko` or `zh` (Simplified Chinese), and not the transcript's own
`language`. The answer is JSON:

```json
{"key": "youtube:nI_owrxLoOQ", "language": "en", "source_language": "es", "title": "...",
 "summary": "...", "key_points": ["..."], "chapters": [{"start": 37, "title": "..."}], "notes_md": "...",
 "action_items": [], "sections": [...], "chat_pills": ["..."], "created_at": "...", "cached": false}
```

The fields are the same as a transcript's, with the same timestamps. An AI model translates the notes, not the
transcript: the segments stay in the spoken language. A new translation takes 5 to 15 seconds, needs a signed-in
account and costs AI credits (401 `signed_out` or 402 `insufficient_credits` otherwise; the answer then also has
`"credits"`). It is stored for everyone: after that, the same key and language answer at once with
`"cached": true`, free and with no sign-in.

## Errors

Refused requests get an HTTP status and `{"detail": {"code": "...", "message": "..."}}`:

| Status | code | When |
| --- | --- | --- |
| 400 | `empty`, `unsupported_url` | `/transcribe` without a link, or `/check` or `/transcribe` with a link we do not accept |
| 400 | `bad_request` | `/check` without `url` or `platform` + `id`; `/ask` with an empty or over-long question or a bad `role`; `/translate` with a `to` not in the list |
| 400 | `same_language` | `/translate` into the language the notes are already in |
| 401 | `signed_out` | `/ask`, or `/translate` for a new translation, without a signed-in account |
| 402 | `insufficient_credits` | `/ask` or a new `/translate`: the account has too few AI credits |
| 404 | `not_found` | `/ask`, `/translate` or `/prices`: no transcript has that key |
| 409 | `no_notes` | `/translate`: the transcript has no summary or notes to translate |
| 422 | — | a parameter or body of the wrong type, such as an `/ask` body that is not JSON of the shape above; `detail` is then FastAPI's list of problems |
| 429 | `rate_limit` | a daily or running limit; the body also has `scope`: `transcribe_concurrent` or `transcribe_daily` (per IP), `ask_account` or `translate_account` (per account) |
| 502 | `translation_failed` | `/translate`: the model gave no usable translation; try again |
| 503 | `busy` | today's capacity for new transcriptions, questions or translations is used up |
| 503 | `unavailable` | `/ask` or `/translate`: switched off |

## API tokens (scripts and automations)

Create a token in the workspace under Connected apps (https://blumify.io/app/apps) and send it as
`Authorization: Bearer blm_…`. A token acts as its account: work it starts is paid with the account's AI credits
at the normal rates. A "Read" token lists, reads and exports; a "Read + spend credits" token also starts work and
spends credits. The token is shown once (only its start is kept), and revoking it stops it at once.

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/me/transcripts` | `{"url", "speakers"?, "num_speakers"?}`: a private transcript, paid with credits (a public one that already exists is saved for free). Returns `key`, `jobId` and `links` |
| GET | `/me/transcripts` | the workspace, newest first (`?limit`, up to 200) |
| GET | `/me/transcripts/{key}` | one transcript; `?segments=1` adds the text |
| GET | `/me/transcripts/{key}/export` | `?format=` txt, srt, vtt, md or json |
| POST | `/me/transcripts/{key}/ask` | `{"question", "thread"?}` → `{answer, credits, sig}` (paid) |
| POST | `/me/transcripts/{key}/translate` | `{"to"}`: the notes in another language (paid once per language) |
| POST | `/me/transcripts/{key}/speakers` | `{"num_speakers"?}`: speaker labels (paid) |
| GET | `/me/jobs/{id}` | the job's state; `?wait=` up to 50 seconds for it to end |
| POST | `/me/jobs/{id}/cancel` | stop a running job |
| GET | `/me/credits` | credits available and held, and the plan |
| POST, GET | `/me/playlists`, `/me/playlists/{id}/confirm`, `/me/playlists/{id}` | price, run and follow a YouTube playlist |

Each token: 60 requests a minute and 5,000 a day. Errors: 401 `missing_token`, `wrong_token` (not a `blm_`
token) or `invalid_token` (revoked); 403 `insufficient_scope` (a read token tried to spend); 402
`insufficient_credits`; 429 `rate_limit` with a `Retry-After` header.

```bash
curl -X POST https://blumify.io/api/media/v1/me/transcripts -H "Authorization: Bearer $BLUMIFY_TOKEN" \
  -H "Content-Type: application/json" -d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
curl "https://blumify.io/api/media/v1/me/jobs/JOB_ID?wait=50" -H "Authorization: Bearer $BLUMIFY_TOKEN"
curl "https://blumify.io/api/media/v1/me/transcripts/KEY/export?format=md" -H "Authorization: Bearer $BLUMIFY_TOKEN"
```

## Follow creators

An account can follow creators (YouTube channels, podcasts) that have public transcripts here: 3 on a free
account, as many as you like on Pro. With an API token (any access) or the site's session:

- `GET /me/follows` · `POST /me/follows` `{"creator"}` (a name or slug) · `DELETE /me/follows/{slug}`. Past the
  free limit, `POST` answers 402 `follow_limit`.
- `GET /me/feed?since=<next>` — new public transcripts from followed creators, oldest first, with summaries. Pass
  the previous answer's `next` to get only newer ones; each item has an `eventId`, so nothing is handled twice.

The signed-in MCP server has the same as tools: `follow_creator`, `unfollow_creator`, `list_following` and
`whats_new`. People who follow also get one email a day at most, only when something new arrives. On Pro, new
episodes of followed YouTube channels are transcribed automatically (`POST /me/follows/{slug}/auto` `{"on"}`).

## Webhooks

Instead of polling, give an https URL and we POST to it when something happens. Add one in Connected apps, or with
an API token (any access) or the site's session:

- `GET /me/webhooks` · `POST /me/webhooks` `{"url", "events": [...]}` (answers the signing `secret`, once) ·
  `DELETE /me/webhooks/{id}` · `POST /me/webhooks/{id}/test` (sends a `ping` now).
- Events: `transcript.ready` (a private transcript of yours is done: `key`, `title`, `jobId`, `credits`, `url`,
  `api`), `job.failed` (`jobId`, `key`, `code`; no credits used), `creator.new_transcript` (a creator you follow
  has a new public transcript: the same item as `/me/feed`).
- Body: `{"id": "evt_…", "type", "created", "data"}`. The `id` stays the same when a delivery is retried: drop
  repeats. Answer 2xx within 10 seconds; anything else is retried, and after 20 failures in a row the webhook is
  switched off and you get an email.
- Up to 5 per account, https on port 443 or 8443, on a public address. Redirects are not followed.

Check the `Blumify-Signature` header (`t=<unix time>,v1=<hex>`) before trusting a call:

```python
import hashlib, hmac, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, sig = parts["t"], parts["v1"]
    want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, sig) and abs(time.time() - int(t)) <= tolerance
```

## MCP

Two MCP servers (Streamable HTTP, JSON-RPC):

- `https://blumify.io/api/media/v1/mcp/public` — no sign-in. One tool, `get_transcript`, reads transcripts that already exist.
- `https://blumify.io/api/media/v1/mcp` — signed in with OAuth 2.1 (the app opens a page where the person signs in and approves it).
  Private transcripts, questions, translations, speaker labels, playlists and follows, paid with the person's
  credits. Discovery: `https://blumify.io/.well-known/oauth-protected-resource/api/media/v1/mcp`.

People-facing overview, with an n8n workflow: https://blumify.io/developers

## Limits

- New transcriptions through `/transcribe` without signing in: 2 running at once and 3 a day per IP address.
  Cached links and joining a job that is already running do not count. For more, sign in through the MCP server
  (`transcribe_link`, paid with the account's credits).
- Questions through `/ask`: 300 a day per account. New translations through `/translate`: 100 a day per
  account. Stored translations do not count.

Days are UTC. Site-wide daily caps also apply, and answer 503 `busy` when reached.

## Example (Python)

```python
import json
import requests

API = "https://blumify.io/api/media/v1"


def transcribe(url):
    with requests.get(f"{API}/transcribe", params={"url": url}, stream=True, timeout=(10, 60)) as r:
        if r.status_code != 200:
            raise RuntimeError(r.json()["detail"])
        event = None
        for line in r.iter_lines(decode_unicode=True):
            if line.startswith("event: "):
                event = line[len("event: "):]
            elif line.startswith("data: "):
                data = json.loads(line[len("data: "):])
                if event == "stage":
                    print("stage:", data["stage"], data["progress"] or "")
                elif event == "done":
                    return data
                elif event == "error":
                    raise RuntimeError(f"{data['code']}: {data['message']}")


t = transcribe("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
print(t["permalink"])
print(t["summary"])
# The whole transcript, to answer questions from yourself:
text = "\n".join(s["text"] for s in t["segments"])
```

## Contact

gaurav@blumify.io
