# Podsworth API import { Card, CardGrid } from "@astrojs/starlight/components"; One recording, one to eight tracks. We measure every file, charge credits for what we measure, and deliver each output with its own link. [Projects →](/guides/projects/) Upload straight to storage with a presigned form, or have us import from any https URL, Dropbox or Google Drive. [Uploads and imports →](/guides/uploads-and-imports/) Test keys run your whole integration, webhooks included, through a simulator. Nothing is processed and nothing is charged. [Test mode →](/guides/test-mode/) Signed events for every status change and every credit grant, retried for three days, plus an events feed to catch up. [Webhooks →](/guides/webhooks/) ## The basics - **Base URL:** `https://api.podsworth.com`. Live and test keys use the same host. - **Authentication:** `Authorization: Bearer pw_test_…` (or `pw_live_…`). Owners and admins create keys on their account page. See [Authentication](/guides/authentication/). - **Format:** JSON in and out, camelCase fields, timestamps in UTC (ISO 8601). Errors look like `{"error": {"code": "…", "message": "…"}}`. See [Errors and limits](/guides/errors-and-limits/). - **Credits:** one credit is one minute of audio, charged by the second for what we measure. Live projects spend the account's credits when submitted; failed or cancelled projects get them back. See [Credits](/guides/credits/). - **For agents:** [llms.txt](/llms.txt), [all guides in one file](/llms-full.txt) and the [OpenAPI file](/openapi.json). --- # Authentication Every request carries an API key: ```http Authorization: Bearer pw_live_... ``` ## Keys Owners and admins of an account create and revoke keys on its account page (people only: a key can't make keys). A key's secret is shown once; we keep only a hash, so a lost key can't be recovered. Revoke it and create another. An account can have 50 active keys. | Prefix | Mode | What it does | |---|---|---| | `pw_live_` | live | Real projects, processed by our studio pipeline and paid with the account's credits. | | `pw_test_` | test | Simulated projects: validated and priced like live ones, never processed, never charged. See [Test mode](/guides/test-mode/). | A key sees only its own mode's projects, events and webhook endpoints: live and test never mix. ## Permissions A key holds some of these permissions, chosen when it's made. It can't have one its creator doesn't have. | Permission | Allows | |---|---| | `account:read` | The account's balance, credit history, purchases, and `credits.*` events. | | `projects:read` | Reading projects and downloading their outputs; the events feed. | | `projects:write` | Creating projects, uploading, submitting test projects, cancelling, deleting files. | | `credits:spend` | Submitting live projects (they spend credits). Live keys only. | | `billing:purchase` | Buying credits with the account's saved card. Live keys only. | | `webhooks:manage` | Webhook endpoints and their deliveries. | A request that needs a permission the key doesn't have gets `403` with code `missing_scope`. A test key asking to spend or buy gets `403` `test_mode`. ## Keys follow the person who made them A key never does more than its creator can do **now**. If the creator's role on a team changes, the key loses what the new role can't do; if they leave the team or their user is disabled, the key stops working (`401`). Keys for a long-running integration are best made by an owner. ## Checking a key ```bash curl https://api.podsworth.com/v1/me -H "Authorization: Bearer $PODSWORTH_KEY" ``` ```json { "accountId": "acc_…", "actorType": "api_key", "actorId": "key_…", "mode": "test", "scopes": ["projects:read", "projects:write"] } ``` `scopes` are the permissions the key has right now. --- # Quickstart This walks through one project with a **test key**: everything behaves like a live project (statuses, webhooks, the delivery) except that nothing is processed and nothing is charged. Switch to a live key when you're ready. ## 1. Create a test key Sign in at [app.podsworth.com](https://app.podsworth.com), open your account page, and under **API keys** create a key in **Test** mode with these permissions: read projects, create and upload projects. Copy the secret: it's shown once. ```bash export PODSWORTH_KEY=pw_test_... ``` ## 2. Create a project ```bash curl https://api.podsworth.com/v1/projects \ -H "Authorization: Bearer $PODSWORTH_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Episode 12", "files": [{"fileName": "episode-12.wav"}]}' ``` The project starts as a `draft`. Each file has an `upload`: a URL and the form fields to send with it. ```json { "id": "prj_9f2c41d0b8e64a7c9a11", "status": "draft", "mode": "test", "files": [ { "position": 0, "fileName": "episode-12.wav", "uploadStatus": "pending", "upload": { "url": "https://…s3.amazonaws.com/", "fields": { "key": "uploads/…", "policy": "…", "…": "…" } } } ] } ``` ## 3. Upload the file POST the file to `upload.url` as `multipart/form-data`: every field from `upload.fields`, then the file itself as `file` (it must come last). ```bash curl https://…s3.amazonaws.com/ \ -F key=uploads/… -F policy=… -F x-amz-signature=… \ -F file=@episode-12.wav ``` ## 4. Submit it ```bash curl -X POST https://api.podsworth.com/v1/projects/prj_9f2c41d0b8e64a7c9a11/submit \ -H "Authorization: Bearer $PODSWORTH_KEY" ``` The project goes to `validating`: we check every file, measure it, and (with a live key) charge its credits. Then it's `queued`, or `rejected` with a reason you can fix before submitting again. ## 5. Follow it and download the result Poll `GET /v1/projects/{id}` (every 10 seconds or so), or add a [webhook](/guides/webhooks/) and wait for `project.completed`. A test project takes about a minute. ```bash curl https://api.podsworth.com/v1/projects/prj_9f2c41d0b8e64a7c9a11 \ -H "Authorization: Bearer $PODSWORTH_KEY" ``` When it's `completed`, `downloadUrl` is the whole delivery as a zip, `outputs` lists each file with its own link, and `manifestUrl` describes them. Links last an hour: fetch the project again for fresh ones. ## The same in Python ```python import os import time import requests API = "https://api.podsworth.com" HEADERS = {"Authorization": f"Bearer {os.environ['PODSWORTH_KEY']}"} project = requests.post(f"{API}/v1/projects", headers=HEADERS, json={ "name": "Episode 12", "files": [{"fileName": "episode-12.wav"}]}).json() upload = project["files"][0]["upload"] with open("episode-12.wav", "rb") as f: requests.post(upload["url"], data=upload["fields"], files={"file": f}).raise_for_status() requests.post(f"{API}/v1/projects/{project['id']}/submit", headers=HEADERS).raise_for_status() while project["status"] not in ("completed", "failed", "rejected", "cancelled"): time.sleep(10) project = requests.get(f"{API}/v1/projects/{project['id']}", headers=HEADERS).json() print(project["status"], project["downloadUrl"] or project["rejection"] or project["failure"]) ``` ## The same in TypeScript Node 20 or later (built-in `fetch`, `FormData` and `Blob`): ```ts import { readFile } from "node:fs/promises"; const API = "https://api.podsworth.com"; const headers = { Authorization: `Bearer ${process.env.PODSWORTH_KEY}`, "Content-Type": "application/json" }; let project = await (await fetch(`${API}/v1/projects`, { method: "POST", headers, body: JSON.stringify({ name: "Episode 12", files: [{ fileName: "episode-12.wav" }] }), })).json(); const { url, fields } = project.files[0].upload; const form = new FormData(); for (const [name, value] of Object.entries(fields)) form.append(name, value as string); form.append("file", new Blob([await readFile("episode-12.wav")]), "episode-12.wav"); if (!(await fetch(url, { method: "POST", body: form })).ok) throw new Error("upload failed"); await fetch(`${API}/v1/projects/${project.id}/submit`, { method: "POST", headers }); while (!["completed", "failed", "rejected", "cancelled"].includes(project.status)) { await new Promise((r) => setTimeout(r, 10_000)); project = await (await fetch(`${API}/v1/projects/${project.id}`, { headers })).json(); } console.log(project.status, project.downloadUrl ?? project.rejection ?? project.failure); ``` ## Next - [Projects](/guides/projects/): statuses, multi-track projects, settings, outputs. - [Webhooks](/guides/webhooks/): stop polling. - [Test mode](/guides/test-mode/): force a failure to test your error handling. --- # Projects A project is one recording: one file, or two to eight tracks of the same recording (host, guest, …) that are processed together. You create it, put its files in place (upload or import), submit it, and follow it to a delivery. ## Creating a project ```http POST /v1/projects ``` ```json { "name": "Episode 12", "clientRef": "ep-12", "metadata": { "show": "Acme Weekly" }, "settings": { "noiseLevel": "n2", "plosivesLevel": "p2", "mergeOutput": false }, "files": [{ "fileName": "host.wav" }, { "fileName": "guest.wav" }] } ``` | Field | | |---|---| | `files` | 1 file: processed alone. 2–8: tracks of one recording. Video files (MP4, AVI, …) can't be grouped. Each file is an upload (the default) or an import: see [Uploads and imports](/guides/uploads-and-imports/). | | `name` | Optional; names the delivery's zip and folder. | | `clientRef` | Your own id, unique within the account. Filter by it: `GET /v1/projects?clientRef=ep-12`. | | `metadata` | Up to 20 string keys and values, returned as given. | | `settings.noiseLevel` | `n1` light, `n2` medium (default), `n3` strong noise reduction. | | `settings.plosivesLevel` | `p1` light, `p2` medium (default), `p3` strong plosive reduction. | | `settings.mergeOutput` | Two-track projects only: one merged file instead of two. | ## Statuses | Status | Meaning | |---|---| | `draft` | Created. Upload (or wait for the imports of) its files, then submit. | | `validating` | Submitted: we check every file is in, measure it, apply the rules and charge the credits. | | `rejected` | Refused at validation; `rejection` says why. Fix it and submit again. | | `queued` | Waiting for the studio. `estimatedCompletionAt` estimates when it'll be done. | | `processing` | Being processed; `progress` says the step and roughly how far along. | | `completed` | Done: download the outputs. | | `failed` | We couldn't process it after retrying; its credits were refunded. | | `stopped` | Held by our team (rare). It's queued again or cancelled. | | `cancelled` | Cancelled before processing; its credits were refunded. | A project can go back to `queued` (and `processing`) when we retry it. Every change is also a [webhook event](/guides/webhooks/). ### Rejections | `rejection.code` | Fix | |---|---| | `upload_missing` | A file wasn't uploaded. Upload it and submit again. | | `unreadable_file` | We couldn't read a file as audio or video. That file gets a new upload location: upload it again and submit. | | `insufficient_credits` | The account doesn't have enough credits. Buy more (the message names a bundle that covers it) and submit again. | | `measurement_unavailable` | We couldn't check a file just now. Submit again in a minute. | | `file_too_large` | Test mode only: a file is over 500 MB. | | `test_limit_reached` | Test mode only: today's test limits are used up. | | `import_failed` | An import couldn't be downloaded; the file's `importError` says why. Create the project again with a working link. | ## What it costs `creditsSeconds` is the project's price in credits (seconds of audio): the measured length of all its files, with a small per-project minimum. Every setting costs the same. A live project's credits are taken when it's validated and given back if it fails or is cancelled. See [Credits](/guides/credits/). ## Following a project ```http GET /v1/projects/{id} GET /v1/projects?status=queued&limit=50&cursor=… ``` Lists are newest first, at most 100 per page; pass `nextCursor` as `cursor` for the next page. - `estimatedCompletionAt` (queued and processing): when we expect it done, from the queue ahead of it and the studio's capacity. An estimate, refreshed on every read; `null` while the studio is offline. - `progress` (processing): `step` (e.g. "Isolating and enhancing dialogue"), `percent`, and `rate`/`cap` if you animate a progress bar between polls (move at most `rate` percent per second, never past `cap`). ## Outputs A `completed` project has: - `downloadUrl`: the whole delivery as one zip (a folder named after the project). - `outputs`: each delivered file (`kind`: `audio`, `video` or `transcript`) with its own `url`. Test projects list them today; live projects will shortly (until then, use the zip). - `manifestUrl`: `manifest.json`, describing the outputs, the inputs they came from and the settings. Every link lasts an hour; read the project again for fresh ones. A one-file project delivers `_pw_vs.` and `_transcript.txt`; a multi-track project delivers each track (or one merged file) and a transcript. ## Cancelling and deleting files - `POST /v1/projects/{id}/cancel`: before processing starts (`draft`, `validating`, `rejected`, `queued`). Charged credits come back. - `DELETE /v1/projects/{id}/files`: deletes the project's inputs and outputs from storage now. The project and its usage stay. Everything is deleted after 30 days anyway. --- # Uploads and imports Each file in a project arrives one of two ways, chosen per file with `source` when you create the project. ## Uploads (the default) ```json { "fileName": "episode-12.wav" } ``` The file gets an `upload` with a `url` and `fields`. POST to `url` as `multipart/form-data` with every field from `fields`, then the file as `file`, last. The form is valid for three hours and accepts up to 5 GB (500 MB in test mode). Accepted: WAV, MP3, M4A, MP4, AVI, FLAC, OGG, AAC and a few more; we read the file itself, not its name. A file's `uploadStatus` is `pending` until the project is submitted; then `done`. If validation finds a file unreadable, that file gets a new `upload` (new location): upload it again and submit. ## Imports We fetch the file for you. Imports start as soon as the project is created (`uploadStatus: "importing"`, no `upload`); you can submit right away and validation waits for them. | `source` | | |---|---| | `{"type": "url", "url": "https://…"}` | Any public https file, e.g. a signed link from your own storage. | | `{"type": "dropbox", "url": "https://www.dropbox.com/…"}` | A Dropbox share link. | | `{"type": "googleDrive", "fileId": "…", "accessToken": "…"}` | A Google Drive file, with an OAuth access token that can read it (the `drive.file` scope covers files the user picked). We use the token once and don't keep it. | ```json { "files": [ { "fileName": "host.wav", "source": { "type": "url", "url": "https://files.example.com/ep12/host.wav?sig=…" } }, { "fileName": "guest.wav" } ] } ``` Rules for imports: - https only, and the host must resolve to a public address. We follow up to five redirects, checking each one. - Dropbox imports stay on Dropbox's hosts and Drive imports on Google's. - Up to 5 GB per file (500 MB in test mode), and an hour per download. - At most 24 files importing at once per account (`429` `import_limit`). When an import fails because of its link (a 4xx answer, too large, a private address), the file's `uploadStatus` is `import_failed` with the reason in `importError`, and validation rejects the project with `import_failed`. Create the project again with a working link. --- # Test mode Projects created with a test key (`pw_test_…`) go through every step a live project does, with real statuses, real signed webhooks to your test endpoints, and a real delivery, but nothing reaches our studio and nothing is charged. ## What happens 1. Validation is real: uploads and imports are checked and measured, and `creditsSeconds` shows what the project would cost live. No credits move. 2. `queued` for about 5 seconds, then `processing` for 30–60 seconds (fixed per project), with `progress` moving through the same steps as a live project. 3. `completed`. The delivery is **your own files**, laid out and named exactly like a live delivery (each output file, a zip with a folder, `manifest.json`), plus a placeholder transcript. ## Forcing an outcome Create the project with `testOutcome` (test keys only; a live key gets `400` `test_outcome_live`): | `testOutcome` | | |---|---| | `completed` | The default. | | `failed` | Fails 60 % of the way through processing, with `failure.code: "processing_failed"`. | | `slow` | Takes about 10 minutes, to test long waits and `estimatedCompletionAt`. | ```json { "name": "Failure drill", "testOutcome": "failed", "files": [{ "fileName": "ep.wav" }] } ``` ## Limits Test mode protects the studio's capacity: - files up to 500 MB; - 50 test projects created per account in any 24 hours (`429` `test_limit_reached`); - 5 GB of files submitted per account in any 24 hours (a `rejected` project with `test_limit_reached`). Test projects, events and webhook endpoints are separate from live ones: a test key never sees live data, and a live key never sees test data. --- # Webhooks Instead of polling, add an endpoint and we POST each of your account's events to it as it happens. ## Adding an endpoint On your account page under **Webhooks**, or with a key that has `webhooks:manage`: ```bash curl https://api.podsworth.com/v1/webhook-endpoints \ -H "Authorization: Bearer $PODSWORTH_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://example.com/podsworth/webhooks", "eventTypes": ["project.completed", "project.failed"]}' ``` The response holds the endpoint and its signing `secret` (`whsec_…`), shown only now. `eventTypes` defaults to `["*"]`: every type, including ones we add later. An endpoint gets its key's mode: test events only reach test endpoints. Up to 16 endpoints per mode. The URL must be https on a public address. ## Events | Type | When | |---|---| | `project.validating` | The project was submitted. | | `project.rejected` | Validation refused it (`data.object.rejection`). | | `project.queued` | It's waiting for the studio (again, after a retry). | | `project.processing` | Processing started. | | `project.completed` | Done: GET the project for its download links. | | `project.failed` | It failed for good (`data.object.failure`); its credits were refunded. | | `project.stopped` | Our team put it on hold. | | `project.cancelled` | It was cancelled before processing. | | `credits.added` | Credits arrived: a purchase, the trial, a grant, a refund (`data.object.reason`). | | `credits.low` | The balance dropped below the account's threshold (at most once a day). | | `subscription.renewed` | A monthly plan renewed: its month is paid and its hours granted (`data.secondsGranted`). | | `subscription.payment_failed` | A plan's renewal couldn't be charged; Stripe retries, and the plan is `past_due` meanwhile. | | `subscription.canceled` | A plan ended. Its hours stay usable. | | `billing.topup_failed` | Auto top-up couldn't buy credits: `data.object.reason` is `declined` (auto top-up pauses) or `monthly_cap`. | ## The payload ```http POST /podsworth/webhooks HTTP/1.1 Content-Type: application/json User-Agent: Podsworth-Webhooks/1.0 (+https://docs.podsworth.com/webhooks) Podsworth-Event-Id: evt_3b1f… Podsworth-Event-Type: project.completed Podsworth-Signature: t=1791245100,v1=5f2b… ``` ```json { "id": "evt_3b1f…", "object": "event", "type": "project.completed", "mode": "live", "createdAt": "2026-10-06T14:05:00Z", "data": { "previousStatus": "processing", "object": { "object": "project", "id": "prj_9f2c…", "status": "completed", "mode": "live", "name": "Episode 12", "clientRef": "ep-12", "metadata": { "show": "Acme Weekly" }, "creditsSeconds": 2512, "rejection": null, "failure": null, "createdAt": "…", "queuedAt": "…", "startedAt": "…", "completedAt": "2026-10-06T14:05:00Z" } } } ``` `data.object` is the project as it was at that moment, without download links (they expire): fetch `GET /v1/projects/{id}` for those. ## Verifying the signature `Podsworth-Signature` is `t=,v1=." with your secret>`. Compute it over the **raw** request body (before parsing JSON), compare in constant time, and reject a `t` more than five minutes from now. ```python import hashlib import hmac import time def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: parts = dict(item.split("=", 1) for item in header.split(",")) timestamp = int(parts.get("t", "0")) if abs(time.time() - timestamp) > tolerance: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret: string, header: string, body: string | Buffer, toleranceSeconds = 300): boolean { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex"); const given = String(parts.v1 ?? ""); return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)); } ``` ## Answering Answer with any `2xx` within 10 seconds; do slow work afterwards. Anything else (another status, a redirect, a timeout) is a failure and we try again: after 1 and 5 minutes, 30 minutes, 2, 5 and 10 hours, then about every 10–12 hours until the event is three days old. If an endpoint has had no success for three days we turn it off and email the account's owners and admins; fix it and turn it back on from the account page (or `PATCH` with `"enabled": true`). Events can arrive more than once and out of order. Use the event `id` to skip ones you've handled, and the project's own status (or `GET` it) rather than the order events arrived in. ## Testing an endpoint `POST /v1/webhook-endpoints/{id}/test` sends a `ping` event right away (not retried), and `GET /v1/webhook-endpoints/{id}/deliveries` shows every delivery's status, attempts, and the start of what your endpoint answered. Retry one with `POST …/deliveries/{deliveryId}/retry`. Test keys' projects make realistic test events: see [Test mode](/guides/test-mode/). ## Rolling the secret `POST /v1/webhook-endpoints/{id}/rotate-secret` returns a new secret, used from the next delivery on. A replayed request (with the same `Idempotency-Key`) answers `"secret": null`: roll it again if you missed it. ## The events feed `GET /v1/events` lists the same events, newest first, kept 30 days: catch up after an outage, or poll instead of receiving webhooks. Filter with `type=project.completed` or `type=project.*`; page with `cursor`. `credits.*` events need `account:read`. --- # Errors and limits ## Errors Errors have an HTTP status and a body with a stable `code` to branch on and a `message` to show a person: ```json { "error": { "code": "insufficient_credits", "message": "This project needs 41 min of credits and the account has 12 min." } } ``` | Status | Means | Some codes | |---|---|---| | `400` | The request is wrong. `validation_error` lists the fields in `details`. | `validation_error`, `invalid_file_count`, `video_in_project`, `invalid_merge`, `invalid_source`, `invalid_url`, `invalid_cursor` | | `401` | No key, an invalid or revoked key, or its creator left the account. | | | `403` | The key can't do this. | `missing_scope`, `test_mode`, `account_suspended` | | `404` | Not found, or not yours (another account's, or the other mode's). | `not_found` | | `409` | It conflicts with the current state. | `invalid_transition`, `duplicate_client_ref`, `idempotency_key_reused`, `endpoint_limit` | | `413` | The body is over 1 MB. | `payload_too_large` | | `429` | Too many requests or a limit reached. | `rate_limited`, `test_limit_reached`, `import_limit` | | `5xx` | Our fault. Retry with backoff (safely, with an `Idempotency-Key`). | | A project that validation refuses isn't an error response: it's `rejected` with a `rejection.code` (see [Projects](/guides/projects/#rejections)). ## Rate limits 120 requests a minute per key. Over that you get `429` `rate_limited` with a `Retry-After` header (seconds). Poll a project every 10 seconds or so, or use [webhooks](/guides/webhooks/). ## Idempotent retries Send an `Idempotency-Key` header (any unique string up to 255 characters) on a POST to make retrying it safe. Within 24 hours, the same key with the same body returns the first response again, with `Idempotent-Replayed: true`, instead of acting twice. The same key with a different body is `409` `idempotency_key_reused`; a retry while the first request is still running is `409` `idempotency_in_progress`. A `5xx` isn't kept, so retrying it runs again. Keys belong to the API key that sent them. ```bash curl https://api.podsworth.com/v1/projects \ -H "Authorization: Bearer $PODSWORTH_KEY" -H "Idempotency-Key: ep-12-create" \ -H "Content-Type: application/json" -d '{"files": [{"fileName": "ep12.wav"}]}' ``` ## Pagination Lists (`/v1/projects`, `/v1/events`, deliveries) return the newest first and a `nextCursor`; pass it back as `cursor` for the next page, until it's `null`. `limit` is 20 by default, at most 100. --- # Credits Live projects are paid with the account's **credits**: one credit is one minute of audio, charged by the second for what we measure in the files (all tracks of a project), with a small per-project minimum. Every processing setting costs the same. Credits don't expire. - A live project's credits are taken when it's validated (submitting needs `credits:spend`). Not enough credits: the project is `rejected` with `insufficient_credits`. - A project that fails for good, or is cancelled before processing, gets its credits back automatically. - Test projects show their price in `creditsSeconds` and are never charged. Buy credits as bundles, or as a monthly plan, on the account page (or [podsworth.com/pricing](https://podsworth.com/pricing)). ## Monthly plans A plan adds its hours every month, for a little less than the same bundle. They're spent before any other credits. Unused plan hours roll over, at most one month's worth: at each renewal anything above a month's hours is dropped before the new month's are added. Bundle hours are never touched. Change or cancel a plan in **Billing details** on the account page; changes start at the next renewal, and after a cancellation the hours you have stay usable. `GET /v1/subscription` (`account:read`) shows the plan: its `status` (`active`, or `past_due` while a renewal's payment is retried), `seconds` per month, and `currentPeriodEnd` (when it renews, or ends if `cancelAtPeriodEnd`). Subscribing happens on the account page. ## Auto top-up Auto top-up buys a bundle with the saved card whenever the balance drops below a threshold, at most a set number of times a month (3 by default, up to 20), so an integration never runs dry and a runaway one can't drain a card. ```http PUT /v1/auto-top-up (billing:purchase) ``` ```json { "enabled": true, "thresholdSeconds": 7200, "bundleId": "5h", "monthlyCap": 3 } ``` If a charge is declined (or the bank wants 3-D Secure, which nobody is there to answer), auto top-up pauses, the account's owners and billing members get an email, and a `billing.topup_failed` event goes out. Saving the settings again turns it back on. `GET /v1/auto-top-up` shows the settings, a pause and its reason, and this month's purchases. ## Balance and history ```http GET /v1/balance (account:read) GET /v1/ledger (account:read) ``` `GET /v1/balance` answers `totalSeconds` and `buckets` (seconds left in each: plan, promo, purchased). `GET /v1/ledger` lists every change: purchases, the trial, grants, a project's charge (`debit`) and refunds, newest first. ## Buying from the API With a **live** key that has `billing:purchase`, `POST /v1/credits/buy` buys a bundle with the account's saved card (the card used for its last purchase on the account page): ```json { "bundleId": "5h" } ``` `status` is `paid` when the credits are in (a `credits.added` event follows), or `pending` when the card's bank wants 3-D Secure. A person has to complete that in a browser, so buy ahead of need when you automate it, and watch `credits.low`. `GET /v1/credits/purchases` lists the account's purchases. --- # AI assistants (MCP) Podsworth is a remote [MCP](https://modelcontextprotocol.io) server. An AI assistant connected to it can check your balance, estimate a cost, create and submit projects, follow them and fetch the downloads, using the same projects and credits as the API. ``` https://api.podsworth.com/mcp ``` ## Connecting Add that address to your assistant as a remote MCP server (in Claude: a custom connector). The first time it's used, the assistant opens Podsworth's sign-in page, then a page where you choose: - **the account** it acts for, if you're in a team; - **live or test**: a test connection only sees and makes [test projects](/guides/test-mode/), which are free; - **what it may do**: it can always see the account's balance and projects. You can also let it **create projects and spend credits** on them, with a monthly cap in hours, and **buy credits with the account's saved card**, with a monthly cap in dollars. The assistant never sees your password. It can never do more than your own role in the account allows, and it stops working if you leave the account, sign out everywhere or change your password. See and disconnect your connected apps on your account page. ### Claude Code ```sh claude mcp add --transport http podsworth https://api.podsworth.com/mcp ``` Then run `/mcp` in Claude Code to sign in. Clients that can't sign in through a browser can use an [API key](/guides/authentication/) instead: ```sh claude mcp add --transport http podsworth https://api.podsworth.com/mcp \ --header "Authorization: Bearer pw_test_…" ``` ## Tools | Tool | What it does | |---|---| | `get_account` | The balance, live or test, what this connection may do, its caps and how much of them is used, and the bundles on sale. | | `estimate_cost` | What files of these lengths would cost, and whether the balance covers it. Charges nothing. | | `create_project` | A draft project. Files with a `url` (a public https link or a Dropbox share link) start importing at once; the others come back with an upload URL. | | `get_upload_link` | A page for you to drop your audio files into a project the assistant created, for when the files are on your computer. It works for 24 hours. | | `submit_project` | Measures the files, charges the credits and queues the project, or rejects it with a reason. | | `get_project` | Status, progress, cost, `estimatedCompletionAt`, why it was rejected or failed. | | `list_projects` | The account's projects, newest first. | | `get_downloads` | Links to a completed project's files (valid for an hour). | | `cancel_project` | Cancels before processing starts; the credits come back. | | `delete_project_files` | Deletes the uploaded and processed files now. | | `buy_credits` | Buys a bundle with the saved card, within the monthly cap; without that permission, returns a link for you to buy it. | ## Getting audio in Assistants in a chat usually can't attach files from your computer to a tool call. Give them a link instead (a public https URL or a Dropbox share link), or tell them the file names: they create the project and give you an **upload page** (`get_upload_link`) where you choose each file, then submit it when you say you're done. Assistants that can run commands, such as Claude Code, can also upload themselves: each file in `create_project`'s answer has an `upload.url` and `upload.fields`; POST the fields and then the file as multipart/form-data, as in the [quickstart](/quickstart/). ## Limits and money - A project costs what it would through the API; the assistant can't go past the monthly cap you set. A submit over the cap is rejected with `spend_cap_reached`, and the project waits for you to submit it or connect the assistant again with a higher cap. - Buying over the monthly purchase cap is refused with `purchase_cap_reached`. If the bank asks the cardholder to confirm a purchase, the assistant gets a link for you. - Requests count toward the same rate limits as an API key: 120 a minute per connection.