diff --git a/apps/web/__tests__/unit/developer-billing-docs.test.ts b/apps/web/__tests__/unit/developer-billing-docs.test.ts index 525d0799846..d9ce25b1e9f 100644 --- a/apps/web/__tests__/unit/developer-billing-docs.test.ts +++ b/apps/web/__tests__/unit/developer-billing-docs.test.ts @@ -135,9 +135,11 @@ describe("the Billing page", () => { compileMDX({ source: rest, components: {} }), ).resolves.toBeDefined(); + const slugsOf = (source: string) => + extractHeadings(source.replace(/^####\s/gm, "### ")).map((h) => h.slug); const anchors = new Map([ - ["/docs/api/rest-api", extractHeadings(rest).map((h) => h.slug)], - ["/docs/api/billing", extractHeadings(billing).map((h) => h.slug)], + ["/docs/api/rest-api", slugsOf(rest)], + ["/docs/api/billing", slugsOf(billing)], ]); const links = [ ...billing.matchAll(/\]\((\/docs\/api\/rest-api)#([\w-]+)\)/g), diff --git a/apps/web/__tests__/unit/developer-transcript-docs.test.ts b/apps/web/__tests__/unit/developer-transcript-docs.test.ts index 52c08be6e8e..326417a6a58 100644 --- a/apps/web/__tests__/unit/developer-transcript-docs.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-docs.test.ts @@ -251,7 +251,8 @@ describe("transcript docs keep up with the API", () => { expect(JSON.stringify(schemas.TranscriptDetail)).not.toContain( "confidence", ); - expect(transcriptDocs).toContain("steps of 0.08 seconds"); + expect(transcriptDocs).not.toContain("steps of 0.08 seconds"); + expect(JSON.stringify(schemas.TranscriptDetail)).not.toContain("0.08"); }); it("states the download limit and the upload expiry", () => { diff --git a/apps/web/__tests__/unit/docs-markdown.test.ts b/apps/web/__tests__/unit/docs-markdown.test.ts new file mode 100644 index 00000000000..b278ee5ad8d --- /dev/null +++ b/apps/web/__tests__/unit/docs-markdown.test.ts @@ -0,0 +1,156 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ from: () => ({ where: async () => [] }) }), + }), +})); +vi.mock("@cap/database/schema", () => ({ organizations: {} })); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({ WEB_URL: "https://cap.so" }), +})); + +import { GET } from "@/app/docs-markdown/[...slug]/route"; +import { CAP_AGENT_PROMPT } from "@/data/agent-prompt"; +import { DOCS_MARKDOWN_ROUTE, docsMarkdownSlug } from "@/lib/agent-markdown"; +import { docToMarkdown } from "@/lib/docs-markdown"; +import { getAllDocs, getDocBySlug } from "@/utils/docs"; +import { proxy } from "../../proxy"; + +const getMarkdown = (slug: string, method = "GET") => + GET(new Request(`https://cap.so/docs/${slug}.md`, { method }), { + params: Promise.resolve({ slug: slug.split("/") }), + }); + +const proseLines = (markdown: string) => { + const lines: string[] = []; + let fence: string | null = null; + for (const line of markdown.split("\n")) { + const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]; + if (fence) { + if (marker && marker[0] === fence[0] && marker.length >= fence.length) + fence = null; + } else if (marker) fence = marker; + else lines.push(line); + } + return lines; +}; + +describe("docsMarkdownSlug", () => { + it.each([ + ["/docs/api/rest-api.md", "text/html", "api/rest-api"], + ["/docs/api/rest-api.mdx", null, "api/rest-api"], + ["/docs/api/rest-api", "text/markdown", "api/rest-api"], + ["/docs/api/rest-api/", "text/markdown", "api/rest-api"], + ["/docs", "text/markdown", "introduction"], + ["/docs.md", null, "introduction"], + ["/docs/api/rest-api", "text/html,*/*;q=0.8", null], + ["/docs/api/rest-api", null, null], + ["/pricing.md", null, null], + ["/docsearch", "text/markdown", null], + ])("%s with Accept %s gives %s", (path, accept, slug) => { + expect(docsMarkdownSlug(path, accept)).toBe(slug); + }); +}); + +describe("docToMarkdown", () => { + const docs = getAllDocs(); + + it.each(docs.map((doc) => [doc.slug, doc] as const))( + "turns %s into plain Markdown", + (_slug, doc) => { + const markdown = docToMarkdown(doc, "https://cap.so"); + expect(markdown.startsWith(`# ${doc.metadata.title}\n`)).toBe(true); + expect(markdown).not.toMatch(/^---\n/); + for (const line of proseLines(markdown)) { + expect(line).not.toMatch(/^\s*<\/?[A-Z]/); + expect(line).not.toMatch(/\]\(\/[^/]/); + } + }, + ); + + it("keeps every code block exactly as written", () => { + const doc = getDocBySlug("api/get-started"); + if (!doc) throw new Error("missing get-started doc"); + const markdown = docToMarkdown(doc, "https://cap.so"); + const blocks = doc.content.match(/```[\s\S]*?\n```/g) ?? []; + expect(blocks.length).toBeGreaterThan(5); + for (const block of blocks) expect(markdown).toContain(block); + }); + + it("shows the copyable prompt and warnings as text", () => { + const agents = getDocBySlug("agents"); + const selfHosting = getDocBySlug("self-hosting"); + if (!agents || !selfHosting) throw new Error("missing docs"); + expect(docToMarkdown(agents, "https://cap.so")).toContain(CAP_AGENT_PROMPT); + const hosting = docToMarkdown(selfHosting, "https://cap.so"); + expect(hosting).toContain( + "> **Security Notice for Production Deployments**", + ); + expect(hosting).toContain( + "[Watch the video](https://cap.so/embed/k310v2vf41d6ffp)", + ); + }); +}); + +describe("docs Markdown route", () => { + it("serves a docs page as Markdown", async () => { + const response = await getMarkdown("api/rest-api"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "text/markdown; charset=utf-8", + ); + expect(response.headers.get("vary")).toBe("Accept"); + const body = await response.text(); + expect(body.startsWith("# REST API\n")).toBe(true); + expect(body).toContain("every 3 seconds"); + expect(body).not.toContain(" { + const response = await getMarkdown("api/nope"); + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe( + "text/markdown; charset=utf-8", + ); + expect(await response.text()).toContain("/docs/api/nope"); + }); + + it("refuses a path outside the docs folder", async () => { + const response = await getMarkdown("../../package"); + expect(response.status).toBe(404); + }); +}); + +describe("proxy routes docs Markdown requests", () => { + const send = (path: string, accept: string, method = "GET") => + proxy( + new NextRequest(`https://cap.so${path}`, { + method, + headers: { accept }, + }), + ); + const rewriteOf = (response: Response) => + response.headers.get("x-middleware-rewrite"); + + it.each([ + ["/docs/api/get-started", "text/markdown"], + ["/docs/api/get-started.md", "text/html,*/*;q=0.8"], + ])("rewrites %s (Accept %s)", async (path, accept) => { + const response = await send(path, accept); + expect(rewriteOf(response)).toBe( + `https://cap.so${DOCS_MARKDOWN_ROUTE}/api/get-started`, + ); + }); + + it("leaves the HTML page alone for a browser", async () => { + const response = await send( + "/docs/api/get-started", + "text/html,application/xhtml+xml,*/*;q=0.8", + ); + expect(rewriteOf(response)).toBeNull(); + expect(response.headers.get("x-middleware-next")).toBe("1"); + }); +}); diff --git a/apps/web/__tests__/unit/infra-agent-prompt.test.ts b/apps/web/__tests__/unit/infra-agent-prompt.test.ts index bf20bd1c6c8..713d19baf87 100644 --- a/apps/web/__tests__/unit/infra-agent-prompt.test.ts +++ b/apps/web/__tests__/unit/infra-agent-prompt.test.ts @@ -61,7 +61,12 @@ describe("Cap Infra agent prompt", () => { ]) { expect(named(CAP_INFRA_AGENT_PROMPT)).toContain(call); } - expect(CAP_INFRA_AGENT_PROMPT).toContain("every 2 seconds"); + expect(CAP_INFRA_AGENT_PROMPT).toContain( + "/videos/{id}/status every 3 seconds", + ); + expect(CAP_INFRA_AGENT_PROMPT).toContain( + "/transcripts/{id} every 5 seconds", + ); expect(CAP_INFRA_AGENT_PROMPT).toContain("Retry-After"); expect(CAP_INFRA_AGENT_PROMPT).toContain( `${DEVELOPER_KEY_REQUESTS_PER_MINUTE} requests a minute`, diff --git a/apps/web/__tests__/unit/proxy-infra-path.test.ts b/apps/web/__tests__/unit/proxy-infra-path.test.ts index 7ba7994e2d3..9a1d65e392e 100644 --- a/apps/web/__tests__/unit/proxy-infra-path.test.ts +++ b/apps/web/__tests__/unit/proxy-infra-path.test.ts @@ -22,6 +22,8 @@ vi.mock("@cap/env", () => ({ vi.mock("@/lib/agent-markdown", () => ({ agentMarkdownResponse: async () => null, + docsMarkdownSlug: () => null, + DOCS_MARKDOWN_ROUTE: "/docs-markdown", })); const FORWARDED = "x-middleware-request-x-cap-infra-path"; diff --git a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx index 28dc276a968..16c22f2769f 100644 --- a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx +++ b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx @@ -43,7 +43,10 @@ export async function generateMetadata( return { title: pageTitle, description, - alternates: { canonical: `https://cap.so${docPath}` }, + alternates: { + canonical: `https://cap.so${docPath}`, + types: { "text/markdown": `https://cap.so${docPath}.md` }, + }, openGraph: { title: pageTitle, description, diff --git a/apps/web/app/docs-markdown/[...slug]/route.ts b/apps/web/app/docs-markdown/[...slug]/route.ts new file mode 100644 index 00000000000..93425840c9e --- /dev/null +++ b/apps/web/app/docs-markdown/[...slug]/route.ts @@ -0,0 +1,23 @@ +import { markdownHeaders, markdownNotFoundBody } from "@/lib/agent-markdown"; +import { docToMarkdown } from "@/lib/docs-markdown"; +import { getDocBySlug } from "@/utils/docs"; + +export async function GET( + request: Request, + props: RouteContext<"/docs-markdown/[...slug]">, +) { + const { slug } = await props.params; + const { origin } = new URL(request.url); + const doc = getDocBySlug(slug.join("/")); + + if (!doc) { + return new Response( + markdownNotFoundBody(origin, `/docs/${slug.join("/")}`), + { status: 404, headers: markdownHeaders("private, no-store") }, + ); + } + + return new Response(docToMarkdown(doc, origin), { + headers: markdownHeaders("public, max-age=300"), + }); +} diff --git a/apps/web/components/pages/infra/Agent.tsx b/apps/web/components/pages/infra/Agent.tsx index 22838004b34..2c10efce563 100644 --- a/apps/web/components/pages/infra/Agent.tsx +++ b/apps/web/components/pages/infra/Agent.tsx @@ -32,7 +32,7 @@ export const Agent = () => (
The full reference is in the{" "}
-
+
docs
.
diff --git a/apps/web/components/pages/infra/content.ts b/apps/web/components/pages/infra/content.ts
index 39fe7aec3da..3ab271653ba 100644
--- a/apps/web/components/pages/infra/content.ts
+++ b/apps/web/components/pages/infra/content.ts
@@ -92,11 +92,12 @@ export const BAA = "BAA available.";
export const TRUST = [COMPLIANCE, BAA, "Open source."] as const;
-const DOCS = "/docs/api/rest-api";
+const REFERENCE = "/docs/api/rest-api";
export const LINKS = {
- docs: DOCS,
- uploadDocs: `${DOCS}#multipart-upload`,
+ docs: "/docs/api/get-started",
+ reference: REFERENCE,
+ uploadDocs: `${REFERENCE}#multipart-upload`,
pro: "/pricing",
trust: "https://trust.cap.so",
signIn: "/login",
@@ -353,7 +354,7 @@ export const infraFaqs: readonly Faq[] = [
{
question: "How do I know when a video is ready?",
answer:
- "Check the video's status from your server every few seconds until it is ready or errored. A video is ready as soon as its first output can be played, and the other output follows.",
+ "Check the video's status from your server every 3 seconds until it is ready or errored. A video is ready as soon as its first output can be played, and the other output follows.",
},
{
question: "How do API keys work?",
diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx
index bbefad8fa1f..d22aca1e2ae 100644
--- a/apps/web/content/docs/api/get-started.mdx
+++ b/apps/web/content/docs/api/get-started.mdx
@@ -351,7 +351,7 @@ Add this to `index.html`, below the upload script:
- **`ready`** means the video can be played. Processing has made at least one of `hlsUrl` and `mp4Url`, and the poster.
- **`hlsUrl` can still be `null` for a moment after `ready`.** The MP4 is often finished first and the adaptive HLS renditions follow a few seconds later. The loop above keeps checking a few more times for `hlsUrl`, then plays the MP4.
- **`errored`** is final. `errorReason` says why, for example that the file could not be read as a video. It is written for you and can mention your credit balance, so log it and show your users your own message. A failed video is not billed and cannot be processed again: create a new upload.
-- **Pace the checks.** A secret key allows 300 requests per minute, and every status check your server makes for any user counts toward it. Leave at least 3 seconds between checks and slow down from there. On a `429`, wait for the `Retry-After` seconds and ask again. See [Rate Limits](/docs/api/rest-api#rate-limits).
+- **Pace the checks.** A secret key allows 300 requests per minute, and every status check your server makes for any user counts toward it. Check every 3 seconds, which usually finds a short video ready on the first check, and slow down for longer ones as the loop above does. On a `429`, wait for the `Retry-After` seconds and ask again. See [Rate Limits](/docs/api/rest-api#rate-limits).
## 5. Play it
@@ -395,28 +395,7 @@ Add this to `index.html`, below the other scripts:
if (width && height) player.style.aspectRatio = `${width} / ${height}`;
if (hlsUrl && window.Hls && Hls.isSupported()) {
- const megabits = navigator.connection?.downlink;
- const estimate = megabits > 0 ? megabits * 800000 : 1500000;
- hls = new Hls({
- autoStartLoad: false,
- abrEwmaDefaultEstimate: estimate,
- abrEwmaDefaultEstimateMax: estimate,
- startFragPrefetch: true,
- capLevelToPlayerSize: true,
- });
- hls.on(Hls.Events.MANIFEST_PARSED, (_event, { levels }) => {
- const wanted = player.clientWidth * window.devicePixelRatio;
- let level = 0;
- while (
- levels[level + 1] &&
- levels[level + 1].bitrate <= estimate &&
- levels[level].width < wanted
- ) {
- level++;
- }
- hls.startLevel = level;
- hls.startLoad();
- });
+ hls = new Hls({ capLevelToPlayerSize: true });
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal || data.response?.code === 403) playMp4();
});
@@ -442,14 +421,13 @@ Safari plays HLS by itself. Every other browser needs [hls.js](https://github.co
| Setting | Why |
|---------|-----|
-| `autoStartLoad: false`, then `startLevel` and `startLoad()` | Start on a rendition the viewer's connection can carry. Without it hls.js starts on the first rendition the playlist lists, which is too large for a slow connection |
-| `abrEwmaDefaultEstimate` and `abrEwmaDefaultEstimateMax` | Give hls.js the browser's own estimate of the connection. Safari and Firefox do not give one, so the page assumes a slow connection there: the video starts on the smallest rendition and steps up once hls.js has measured a download |
-| `startFragPrefetch` | Fetch the first segment while the video element is still being set up |
| `capLevelToPlayerSize` | Never load a rendition larger than the player on the page |
| The `403` check | Media links inside the playlist expire after 12 hours. A `403` means one has expired, so switch to the MP4 at once instead of letting hls.js retry |
| `playMp4` on a fatal error | Keep playing from the same position if HLS fails for any other reason |
| `stopHls` at the start of `playVideo` | Stop the previous video's player before starting the next one on the same page |
+There is no start level to set. The master playlist lists a rendition that starts quickly first, and players begin with the first one listed.
+
[Play a Video](/docs/api/rest-api#play-a-video) covers a Content Security Policy, bitrates and what happens when playback stops.
### Use the hosted player instead
@@ -807,7 +785,9 @@ The same secret key turns an audio or video file into text. It is a separate cal
1. Create the transcript with `POST /api/developer/v1/transcripts/uploads`, sending the file's exact size in bytes as `contentLength`. The response holds the transcript's `id` and a signed `upload.url`.
2. `PUT` the file to `upload.url`.
-3. Start it with `POST /api/developer/v1/transcripts/:id/start`, then check `GET /api/developer/v1/transcripts/:id` until `status` is `complete` or `error`, leaving at least 3 seconds between checks. A complete transcript has its `text`.
+3. Start it with `POST /api/developer/v1/transcripts/:id/start`, then check `GET /api/developer/v1/transcripts/:id` every 5 seconds until `status` is `complete` or `error`. A complete transcript has its `text`.
+
+To wait on several transcripts, check them as a batch with one list call per status instead of one call each: `GET /api/developer/v1/transcripts?status=queued&limit=100`, then `status=processing`. Ask for `queued` first: a transcript only moves from `queued` to `processing` to a final status, so one missing from both lists has finished. Get it for its `text`, and keep checking it if its `status` is not yet `complete` or `error`. An app has at most 20 transcripts in flight, and checking each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24.
A file that is already at a public URL needs no upload. Send it as `url` to `POST /api/developer/v1/transcripts` and check the transcript the same way. That includes a video you uploaded here: send its `mp4Url` as the `url` once the video is `ready`.
diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx
index 845b97bc597..a3e76b8f689 100644
--- a/apps/web/content/docs/api/rest-api.mdx
+++ b/apps/web/content/docs/api/rest-api.mdx
@@ -566,7 +566,7 @@ The `status` field moves from `uploading` to `processing` when the upload comple
A failed video is not billed. It stays in the list with its `errorReason`, and its uploaded file is removed 24 hours after processing started, after which `storedBytes` is `0`. It cannot be processed again, so create a new video and upload the file to that one.
-Poll this endpoint every few seconds after completing an upload, and stop when `status` is `ready` or `errored`. Each call counts toward the key's 300 requests per minute.
+Poll this endpoint every 3 seconds after completing an upload, and stop when `status` is `ready` or `errored`. Each call counts toward the key's 300 requests per minute.
A video is `ready` as soon as its first output can be played, so playback can start while the rest is still encoding. The playback URLs are `null` until then. `hlsUrl` is an adaptive HLS master playlist, `mp4Url` is a progressive MP4 and `posterUrl` is a poster image. They need no API key and can be loaded from any origin. [Play a Video](/docs/api/rest-api#play-a-video) shows how to use them, and [Delete Video](/docs/api/rest-api#delete-video) says when they stop working.
@@ -624,7 +624,9 @@ curl -X DELETE https://cap.so/api/developer/v1/videos/abc123def456 \
Send an audio or video file and get text back. Transcription costs $0.10 per audio hour, billed by the second from the app's credit balance.
-A transcript moves through these statuses: `uploading` (waiting for its file), `queued`, `processing`, then `complete` or `error`. Poll [Get Transcript](/docs/api/rest-api#get-transcript) every few seconds until the status is `complete` or `error`. Each key allows 300 requests per minute.
+A transcript moves through these statuses: `uploading` (waiting for its file), `queued`, `processing`, then `complete` or `error`. Poll [Get Transcript](/docs/api/rest-api#get-transcript) every 5 seconds until the status is `complete` or `error`. Each key allows 300 requests per minute.
+
+To wait on several transcripts, poll them as a batch with one [List Transcripts](/docs/api/rest-api#list-transcripts) call per status instead of one Get Transcript each: `GET /api/developer/v1/transcripts?status=queued&limit=100`, then `status=processing`. Ask for `queued` first: a transcript only moves from `queued` to `processing` to a final status, so one missing from both lists has finished. Read it with Get Transcript for its text, and keep it in the batch if the status it returns is not yet `complete` or `error`. An app has at most 20 transcripts in flight, and getting each of 20 every 5 seconds takes 240 requests a minute, most of a key's limit, while the two list calls take 24.
An app can have 20 transcripts `queued` or `processing` at once. Starting another answers `429` with the code `too_many_transcripts` and a `Retry-After` header of 15 seconds. Wait for one to finish, then send the request again. Transcripts in `uploading` do not count until they are started.
@@ -862,7 +864,7 @@ With `?include=words,segments` a complete transcript also carries:
}
```
-`start` and `end` are seconds from the start of the audio, in steps of 0.08 seconds. A word's start is usually within 0.1 seconds of where it is spoken. Its end can run up to 0.4 seconds late and is often the start of the next word, so the timings suit captions and seeking rather than tight cuts at word ends. Timings do not drift on long files.
+`start` and `end` are seconds from the start of the audio. A word's start is usually within 0.1 seconds of where it is spoken. Its end can run up to 0.4 seconds late and is often the start of the next word, so the timings suit captions and seeking rather than tight cuts at word ends. Timings do not drift on long files.
Audio with no speech is not a failure. The transcript completes with `text` set to `""` and `wordCount` set to `0`, and it is billed for its length like any other transcript. A complete transcript with a `wordCount` of `0` is how you tell that no speech was found.
@@ -1480,6 +1482,8 @@ When the upload is already H.264 and fits the tier, Cap can keep its video as up
Every video has a hosted player, returned as `shareUrl` and `embedUrl` when you create an upload and when you get or list a video. Both are built from the video's `playbackId`, need no API key or sign-in, set no cookies and are kept out of search engines.
+The player sets no cookies. Each time a page with the player loads, Cap records one anonymous view with the page's address, so plays can be counted.
+
- `shareUrl` is a page with the player and the video's name. Send it to a viewer as a link.
- `embedUrl` is the player alone, filling whatever frame you give it. Any site can load it in an iframe:
@@ -1550,7 +1554,7 @@ The full video object returned by list and get endpoints:
## Best Practices
- **Use secret keys server-side only.** The `csk_` key should never appear in client-side code. Create each upload on your server and send only its `cut_` upload token to the browser.
-- **Poll video status after upload.** After completing a multipart upload, poll `GET /videos/:id/status` every few seconds until `status` is `ready` or `errored`, then play `hlsUrl` when it is present and `mp4Url` otherwise. Keep polling until `hlsUrl` is set if the player should use HLS. See [Play a Video](/docs/api/rest-api#play-a-video).
+- **Poll video status after upload.** After completing a multipart upload, poll `GET /videos/:id/status` every 3 seconds until `status` is `ready` or `errored`, then play `hlsUrl` when it is present and `mp4Url` otherwise. Keep polling until `hlsUrl` is set if the player should use HLS. See [Play a Video](/docs/api/rest-api#play-a-video).
- **Switch on `code`.** Every error carries a stable `code`. The `error` text can change.
- **Watch the rate limit headers.** A secret key allows 300 requests per minute and an upload token 1,200. Slow down as `X-RateLimit-Remaining` nears zero, and wait for `Retry-After` seconds after a `429`.
- **Store your keys securely.** Use environment variables or a secrets manager.
diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx
index 50f781acd5b..6e68648ce4b 100644
--- a/apps/web/content/docs/introduction.mdx
+++ b/apps/web/content/docs/introduction.mdx
@@ -42,7 +42,7 @@ Cap is built for AI agents too. Codex, Claude Code, Cursor, OpenCode, or any she
- **Embeds** - Embed your recordings in websites, documentation, and apps with a simple iframe snippet.
-- **REST API & Webhooks** - Integrate Cap into your workflow programmatically with a full API and real-time webhook notifications.
+- **REST API & Webhooks** - Host video and transcribe speech in your own product with [Cap Infra](/docs/api/get-started), Cap's video and speech API, and get a [webhook](/docs/api/webhooks) when a video is ready or has failed.
## Getting Started
diff --git a/apps/web/data/infra-agent-prompt.ts b/apps/web/data/infra-agent-prompt.ts
index e048b24a621..25f8679ac4c 100644
--- a/apps/web/data/infra-agent-prompt.ts
+++ b/apps/web/data/infra-agent-prompt.ts
@@ -1,4 +1,8 @@
-import { DEVELOPER_KEY_REQUESTS_PER_MINUTE } from "@/lib/developer-rate-limits";
+import {
+ DEVELOPER_KEY_REQUESTS_PER_MINUTE,
+ DEVELOPER_TRANSCRIPT_POLL_SECONDS,
+ DEVELOPER_VIDEO_STATUS_POLL_SECONDS,
+} from "@/lib/developer-rate-limits";
import { INFRA_MCP_PATH } from "@/lib/infra-mcp-path";
export const CAP_INFRA_POINTER_PROMPT =
@@ -32,9 +36,9 @@ Cap Infra is Cap's video and speech API. All you need is the app's secret key an
2. Keep the key on the server. Send it only from server code, as Authorization: Bearer followed by the key. It never goes into browser code, a client bundle or a public environment variable.
3. Create uploads on the server. For a signed-in user, call ${calls.createUpload} and give the browser only what it returns for that one video: the upload token (cut_), the video id and the embed URL. In production, set corsOrigin to the site's origin.
4. Upload from the browser. With the upload token as the Bearer credential, call ${calls.initiate}, then ${calls.presignPart} with up to 50 part numbers in one request. PUT each part to its signed URL and keep the ETag it answers with, then call ${calls.complete}. If a call answers 401 with the code upload_token_expired, get a new token from the server with ${calls.renewToken} and continue with the same uploadId and ETags.
-5. Wait until it plays. From the server, poll ${calls.videoStatus} no faster than once every 2 seconds. Stop when status is ready or errored, and show errorReason if it failed.
+5. Wait until it plays. From the server, poll ${calls.videoStatus} every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds. Stop when status is ready or errored, and show errorReason if it failed.
6. Play it. Show the video with its embedUrl, or build a player from hlsUrl and mp4Url in ${calls.video}. Use hlsUrl when it is present and mp4Url otherwise. shareUrl is the link to send to someone.
-7. Transcribe when I ask. Transcripts are billed per audio hour, so confirm with me first. For a public URL call ${calls.transcribeUrl}. For a file call ${calls.transcribeFile}, PUT the file to the signed URL it returns, then call ${calls.startTranscript}. Poll ${calls.transcript} at the same pace until status is complete or error.
+7. Transcribe when I ask. Transcripts are billed per audio hour, so confirm with me first. For a public URL call ${calls.transcribeUrl}. For a file call ${calls.transcribeFile}, PUT the file to the signed URL it returns, then call ${calls.startTranscript}. Poll ${calls.transcript} every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error.
8. Stay inside the limit. A secret key allows ${DEVELOPER_KEY_REQUESTS_PER_MINUTE} requests a minute across every endpoint. After a 429, wait for the number of seconds in the Retry-After header before you send the request again.
9. Watch the credits. ${calls.usage} shows the balance and whether uploads are allowed. A 402 means the app needs credit, so tell me and I will add it in the console.
10. Use the MCP server if you want tools. Cap Infra has a hosted MCP server at ${CAP_INFRA_MCP_URL} that takes the same secret key as Authorization: Bearer. Its requests count toward the same limit.
diff --git a/apps/web/lib/agent-markdown.ts b/apps/web/lib/agent-markdown.ts
index 0c90947d4d1..44d855cad67 100644
--- a/apps/web/lib/agent-markdown.ts
+++ b/apps/web/lib/agent-markdown.ts
@@ -28,7 +28,24 @@ export const prefersMarkdown = (accept: string | null) => {
return markdown > 0 && markdown >= html;
};
-const markdownHeaders = (cacheControl: string) => ({
+export const DOCS_MARKDOWN_ROUTE = "/docs-markdown";
+
+const MARKDOWN_EXTENSION = /\.mdx?$/;
+
+export const docsMarkdownSlug = (
+ pathname: string,
+ accept: string | null,
+): string | null => {
+ const explicit = MARKDOWN_EXTENSION.test(pathname);
+ if (!explicit && !prefersMarkdown(accept)) return null;
+ const path = pathname.replace(MARKDOWN_EXTENSION, "").replace(/\/+$/, "");
+ if (path === "/docs") return "introduction";
+ if (!path.startsWith("/docs/")) return null;
+ const slug = path.slice("/docs/".length);
+ return slug ? slug : null;
+};
+
+export const markdownHeaders = (cacheControl: string) => ({
"Content-Type": `${MARKDOWN_TYPE}; charset=utf-8`,
Vary: "Accept",
"Cache-Control": cacheControl,
diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts
index 4eac42d79cc..f19dd6b727e 100644
--- a/apps/web/lib/developer-openapi.ts
+++ b/apps/web/lib/developer-openapi.ts
@@ -8,7 +8,9 @@ import {
DEVELOPER_KEY_REQUESTS_PER_MINUTE,
DEVELOPER_OWNER_REQUESTS_PER_MINUTE,
DEVELOPER_RATE_LIMIT_WINDOW_SECONDS,
+ DEVELOPER_TRANSCRIPT_POLL_SECONDS,
DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE,
+ DEVELOPER_VIDEO_STATUS_POLL_SECONDS,
} from "@/lib/developer-rate-limits";
import {
TRANSCRIPT_FAILURE_CODES,
@@ -542,7 +544,9 @@ export const developerOpenApiSpec = (baseUrl: string) => ({
"Use the REST operations (secret `csk_` key, server-side only) to create uploads, list, inspect and delete videos, transcribe audio and video, and read credit usage.",
"To upload a video, call createUpload from your server. It creates the video and returns an upload token (`cut_`) for that one video. Send the token from the browser as the Bearer credential of the SDK operations: initiateSdkUpload, presignSdkUploadPart for up to 50 parts at a time, then completeSdkUpload. If the token expires before the upload completes, renewUploadToken returns a new one for the same video and the upload continues with the same uploadId.",
"A public `cpk_` key cannot create or upload videos. Only an upload token made with the secret key can.",
- "To transcribe a file at a public URL, call createTranscript. To transcribe a file you hold, call createTranscriptUpload, PUT the file to the URL it returns, then call startTranscript. Poll getTranscript until its status is `complete` or `error`.",
+ `To transcribe a file at a public URL, call createTranscript. To transcribe a file you hold, call createTranscriptUpload, PUT the file to the URL it returns, then call startTranscript. Poll getTranscript every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until its status is \`complete\` or \`error\`.`,
+ "",
+ `After completeSdkUpload, poll getDeveloperVideoStatus every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds until its status is \`ready\` or \`errored\`. A short video is usually ready on the first check.`,
"",
"Create a developer app and its keys at https://cap.so/dashboard/infra. A step by step guide with browser, Node and Python examples is at https://cap.so/docs/api/get-started.",
"",
@@ -698,8 +702,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({
get: {
operationId: "getDeveloperVideoStatus",
summary: "Get video status",
- description:
- "Returns the processing status and, once ready, the playback URLs, with dimensions, duration and transcription state. Poll this after completeSdkUpload. A video can be ready while hlsUrl is still null, so a player that wants HLS keeps polling until hlsUrl is set. Send userId to return the status only when the video was created with that userId.",
+ description: `Returns the processing status and, once ready, the playback URLs, with dimensions, duration and transcription state. Poll this every ${DEVELOPER_VIDEO_STATUS_POLL_SECONDS} seconds after completeSdkUpload. A video can be ready while hlsUrl is still null, so a player that wants HLS keeps polling until hlsUrl is set. Send userId to return the status only when the video was created with that userId.`,
tags: ["Videos"],
security: secretAuth,
parameters: [videoIdParam, ...videoUserScopeParams],
@@ -762,8 +765,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({
post: {
operationId: "createTranscript",
summary: "Transcribe a URL",
- description:
- "Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every few seconds until status is complete or error. An app can have 20 transcripts queued or processing at once: another is refused with 429 too_many_transcripts and a Retry-After header. A field the request does not know returns 400 with code invalid_request.",
+ description: `Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. An app can have 20 transcripts queued or processing at once: another is refused with 429 too_many_transcripts and a Retry-After header. A field the request does not know returns 400 with code invalid_request.`,
tags: ["Transcripts"],
security: secretAuth,
parameters: [idempotencyKeyParam],
@@ -834,8 +836,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({
get: {
operationId: "getTranscript",
summary: "Get a transcript",
- description:
- "Returns the transcript with its status and, once complete, its text. Audio with no speech is billed for its length and completes with an empty text and a wordCount of 0. A transcript that ends in error is never charged. Add include=words or include=segments for timings in seconds.",
+ description: `Returns the transcript with its status and, once complete, its text. Poll it every ${DEVELOPER_TRANSCRIPT_POLL_SECONDS} seconds until status is complete or error. Audio with no speech is billed for its length and completes with an empty text and a wordCount of 0. A transcript that ends in error is never charged. Add include=words or include=segments for timings in seconds.`,
tags: ["Transcripts"],
security: secretAuth,
parameters: [
@@ -1319,7 +1320,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({
words: {
type: ["array", "null"],
description:
- "Each word with its start and end in seconds. Timings are in steps of 0.08 seconds. A start is usually within 0.1 seconds, and an end can run up to 0.4 seconds late, often to the start of the next word.",
+ "Each word with its start and end in seconds. A start is usually within 0.1 seconds, and an end can run up to 0.4 seconds late, often to the start of the next word.",
items: {
type: "object",
required: ["text", "start", "end"],
diff --git a/apps/web/lib/developer-rate-limits.ts b/apps/web/lib/developer-rate-limits.ts
index 5da9b83a798..4647cd94324 100644
--- a/apps/web/lib/developer-rate-limits.ts
+++ b/apps/web/lib/developer-rate-limits.ts
@@ -11,3 +11,9 @@ export const DEVELOPER_AUTH_FAILURES_PER_MINUTE = 30;
// An upload signs one part per request and the smallest part is 5 MB, so this
// is the ceiling on upload speed: 1,200 parts a minute is 6 GB a minute.
export const DEVELOPER_UPLOAD_TOKEN_REQUESTS_PER_MINUTE = 1200;
+
+// The pace every doc, the OpenAPI document and the agent prompt recommend. A
+// short video is playable about 3 seconds after its upload completes and a
+// short transcript takes about 5 seconds, so one check usually finds it done.
+export const DEVELOPER_VIDEO_STATUS_POLL_SECONDS = 3;
+export const DEVELOPER_TRANSCRIPT_POLL_SECONDS = 5;
diff --git a/apps/web/lib/docs-markdown.ts b/apps/web/lib/docs-markdown.ts
new file mode 100644
index 00000000000..de0e2f55af4
--- /dev/null
+++ b/apps/web/lib/docs-markdown.ts
@@ -0,0 +1,80 @@
+import { CAP_AGENT_PROMPT } from "@/data/agent-prompt";
+
+const fenceOf = (text: string) => {
+ const longest = Math.max(
+ 2,
+ ...[...text.matchAll(/`+/g)].map(([run]) => run.length),
+ );
+ return "`".repeat(longest + 1);
+};
+
+const COPYABLE_PROMPT = /^