Credits
POST /api/v1/episodes
Authenticated users with remaining credits. No MPP. Free-tier users who are out of credits get a 402 pointing at the billing upgrade URL — not an MPP challenge.
Returns {"id":"ep_…"}
PodRead offers its text-to-speech service as an API through the Machine Payments Protocol (MPP). You pay per narration with either on-chain stablecoin on the Tempo network or a Stripe shared payment token from a Link wallet. Pricing scales with submission length and voice tier, with a minimum floor.
| Tier | Voices |
|---|---|
| Standard | wren, felix, sloane, archer, gemma, hugo, quinn, theo |
| Premium | elara, callum, lark, nash |
The fastest path uses the mppx CLI. It handles the 402 challenge, on-chain payment, and credential construction automatically.
New to MPP? See the getting-started walkthrough for end-to-end wallet setup.
Call the hosted API directly. Requires USDC.e on Tempo mainnet — see Funding on mainnet below.
# Create an MPP wallet (one-time setup) and fund it with USDC.e on Tempo mainnet — see below
npx mppx account create
# Pay a narration on podread.app — mppx handles the 402 flow automatically
npx mppx https://podread.app/api/v1/mpp/narrations \
-X POST -H "Content-Type: application/json" \
-d '{"source_type":"url","url":"https://www.worksinprogress.news/p/the-secret-behind-japans-railways"}' \
--rpc-url https://rpc.tempo.xyz
podread.app accepts payment in USDC.e (Stargate-bridged USDC) on Tempo at 0x20c0…b9537d11c60e8b50. Native USDC does not exist on Tempo today.
npx mppx account create.Tempo has no native gas token — gas is paid in USDC.e itself, so leave a small buffer above the narration price. About $1 covers several requests.
Run a self-hosted PodRead instance against Tempo's Moderato testnet. Faucet-dispensed pathUSD — no real money changes hands.
# Create an MPP wallet (one-time setup)
npx mppx account create
# Fund the wallet with testnet pathUSD
npx mppx account fund --rpc-url https://rpc.moderato.tempo.xyz
# Pay a narration on your local server
npx mppx http://your-podread-host/api/v1/mpp/narrations \
-X POST -H "Content-Type: application/json" \
-d '{"source_type":"url","url":"https://www.worksinprogress.news/p/the-secret-behind-japans-railways"}' \
--rpc-url https://rpc.moderato.tempo.xyz
Both examples hit POST /api/v1/mpp/narrations anonymously and return a nar_-prefixed Narration id you can poll at GET /api/v1/mpp/narrations/:id. Authenticated users who want to pay per episode hit POST /api/v1/mpp/episodes instead and get an ep_-prefixed Episode attached to their primary podcast. See Authentication paths for the full matrix.
Omitting voice defaults to felix (Standard). Premium voices (elara, callum, lark, nash) cost more than Standard for the same input. The 402 challenge quotes the exact price for your submission — see Pricing.
PodRead accepts the mppx on-chain credential format — any client that produces it works. wevm/mppx is the reference implementation used in the examples above.
Agents paying from a Stripe Link wallet can use @stripe/link-cli, which emits Stripe's shared_payment_token credential type. PodRead accepts SPTs on every /api/v1/mpp/* endpoint — see the link-cli walkthrough for the full flow.
link-cli mpp pay https://podread.app/api/v1/mpp/narrations \
--spend-request-id lsrq_xxx \
--method POST \
--data '{"source_type":"url","url":"https://www.worksinprogress.news/p/the-secret-behind-japans-railways"}' \
--format json
Five steps: request, receive challenge, pay, retry with credential, poll for audio.
curl -X POST https://podread.app/api/v1/mpp/narrations \
-H "Content-Type: application/json" \
-d '{"source_type":"url","url":"https://www.worksinprogress.news/p/the-secret-behind-japans-railways"}'
Without a Payment credential, the server returns 402 Payment Required. The body's prices map quotes the per-scheme price for this specific request, computed from input length and chosen voice. The example below shows a short submission hitting the per-request floor; longer submissions and Premium voices price higher. Always read the price from the response — never hardcode.
HTTP/1.1 402 Payment Required
WWW-Authenticate: Payment
id="a1b2c3d4e5f6…",
realm="podread.app",
method="tempo",
intent="charge",
request="<base64>",
expires="2026-04-10T12:05:00+00:00",
Payment id="b2c3d4e5f6a7…",
realm="podread.app",
method="stripe",
intent="charge",
request="<base64>",
expires="2026-04-10T12:05:00+00:00"
{
"error": "Payment required",
"challenge": {
"id": "a1b2c3d4e5f6…",
"prices": { "tempo": 25, "stripe": 57 },
"currency": "usd",
"methods": ["tempo", "stripe"],
"realm": "podread.app",
"expires": "2026-04-10T12:05:00+00:00",
"deposit_address": "0x1234abcd…"
}
}
Fields in the outer challenge object:
HMAC-SHA256 hex of the tempo challenge — bound to that method's request blob. Stripe-method clients should read the matching id="…" attribute from the WWW-Authenticate header instead. Clients echo the id back unchanged in the credential.
Per-scheme prices in cents: prices.tempo is the on-chain Tempo amount; prices.stripe is the SPT amount. Distinct from request.amount in each per-method blob, which is in token base units (tempo) or fiat cents-as-string (stripe).
Advertised payment methods, in the same order their per-method challenges appear in the WWW-Authenticate header. Always ["tempo", "stripe"] today.
The ISO 4217 code ("usd"). Distinct from request.currency below, which is an ERC-20 contract address for tempo.
Standard base64 (with padding, +// alphabet) — not base64url. Decodes to the object below.
The decoded request blob differs by method. The tempo challenge encodes a token-base-units amount and a deposit recipient:
{
"amount": "250000",
"currency": "0x20c000000000000000000000b9537d11c60e8b50",
"recipient": "0x1234abcd…",
"voice_tier": "standard",
"character_count": 1234
}
Token base units — convert from cents with amount_cents × 10token_decimals / 100. The stablecoin has 6 decimals.
The ERC-20 stablecoin contract address on Tempo. On podread.app this is USDC.e (Stargate-bridged USDC) at 0x20c0…b9537d11c60e8b50; self-hosted testnet deployments default to pathUSD at 0x20c0…0000.
The one-time Stripe-provisioned deposit address.
"standard" or "premium". Part of the HMAC pre-image — swapping tiers between issuance and retry invalidates the credential.
The stripe challenge encodes the fiat-cents amount and the merchant's networkId (Stripe Profile ID), with no on-chain recipient:
{
"amount": "150",
"currency": "usd",
"networkId": "profile_61UbsYpl4SxclGNZQA6UbsYpSGSQ2OwdqYukbeHlQOwS",
"voice_tier": "standard"
}
Fiat cents as a string — Stripe API convention. 150 = $1.50 (Standard SPT) and 250 = $2.50 (Premium SPT).
ISO 4217 code ("usd"). Same value as the outer challenge's currency.
The merchant's Stripe Profile ID — profile_61Ubs… for podread.app. Required by mppx's stripe decoder; carried into the SPT redemption call.
"standard" or "premium". Part of the HMAC pre-image — swapping tiers between issuance and retry invalidates the credential.
Send a USDC transfer on the Tempo network to the deposit_address from the challenge. The transfer must be:
deposit_address returned in the 402 responserequest.amount from the tempo challenge (token base units; convert from cents with amount_cents × 106 / 100)request.currency field — on podread.app that is USDC.e (Stargate-bridged USDC) at 0x20c0…b9537d11c60e8b50expires timestampFor the method="stripe" branch, mint a Stripe shared payment token (SPT) from a Link wallet against the networkId in the stripe challenge's request blob. The SPT must:
request.amount from the stripe challenge — 150 cents for Standard ($1.50), 250 for Premium ($2.50)networkId echoed in request.networkId (the merchant's Stripe Profile ID)expires timestampThe @stripe/link-cli walkthrough covers this end-to-end with copy-paste commands.
Construct a credential and retry the original request with it in the Authorization header. The credential is a base64url-encoded JSON object whose shape depends on the challenge method.
tempo: echo the tempo challenge and supply an on-chain hash or signed tx:
{
"challenge": {
"id": "a1b2c3d4e5f6…",
"realm": "podread.app",
"method": "tempo",
"intent": "charge",
"request": "<base64-encoded-request-json>",
"expires": "2026-04-10T12:05:00+00:00"
},
"payload": {
"hash": "0xabc123…"
}
}
The tempo payload contains either hash (tx hash of an already-submitted transfer, shown above) or signature (a signed raw tx the server will submit on your behalf via eth_sendRawTransaction). The signature must be an RLP-encoded signed Ethereum transaction, hex-prefixed — any valid transaction type (legacy, EIP-2930, EIP-1559) is accepted as long as it transfers the right amount of the stablecoin (USDC.e on podread.app) to the challenge's deposit address.
{
"challenge": { /* same as above */ },
"payload": {
"signature": "0x02f875…"
}
}
stripe: echo the stripe challenge and supply the SPT identifier minted in Step 2:
{
"challenge": {
"id": "b2c3d4e5f6a7…",
"realm": "podread.app",
"method": "stripe",
"intent": "charge",
"request": "<base64-encoded-request-json>",
"expires": "2026-04-10T12:05:00+00:00"
},
"payload": {
"spt": "spt_1QabC2deFGhIJkLmNoPQrStUv"
}
}
The stripe payload carries the spt string (Stripe shared payment token id). The server redeems it via Stripe's PaymentIntent API at verify time.
Encode this JSON as base64url (no padding, -/_ instead of +//) and send it:
curl -X POST https://podread.app/api/v1/mpp/narrations \
-H "Content-Type: application/json" \
-H "Authorization: Payment <base64url-encoded-credential>" \
-d '{"source_type":"url","url":"https://www.worksinprogress.news/p/the-secret-behind-japans-railways"}'
On success, the server returns 201 Created:
{ "id": "nar_aB3xK9mQ2pL7" }
// Payment-Receipt header:
// tx=0xabc123…, payment=mpp_…, sig=<hmac-hex>
id is the created resource's public identifier. The prefix (ep_ vs nar_) tells you whether this was an Episode (from /api/v1/episodes or /api/v1/mpp/episodes) or a Narration (from /api/v1/mpp/narrations).Payment-Receipt is a comma-separated triplet: tx is the on-chain transaction hash, payment is the opaque MppPayment public id (useful for support inquiries), sig is an HMAC-SHA256 over the receipt body signed with the server's secret so clients can verify authenticity.Audio generation is asynchronous. Poll the narration endpoint:
curl https://podread.app/api/v1/mpp/narrations/nar_aB3xK9mQ2pL7
Responses progress through pending → preparing → processing → complete.
When status is complete, the response includes an audio_url:
{
"id": "nar_aB3xK9mQ2pL7",
"status": "complete",
"title": "Example Article",
"author": "Jane Doe",
"duration_seconds": 342,
"audio_url": "https://storage.googleapis.com/…/narrations/abc123.mp3"
}
Narrations expire 24 hours after creation. After expiration, GET /api/v1/mpp/narrations/:id returns 404. Episodes from /api/v1/mpp/episodes persist in the authenticated user's account and are retrieved via the Episodes API instead.
There are three POST endpoints, one per intent. The endpoint you call determines what credentials the server demands and what resource it creates.
POST /api/v1/episodes
Authenticated users with remaining credits. No MPP. Free-tier users who are out of credits get a 402 pointing at the billing upgrade URL — not an MPP challenge.
Returns {"id":"ep_…"}
POST /api/v1/mpp/narrations
No account required. Pay per call via MPP. Creates a Narration — standalone audio, no user, 24h TTL, polled at /api/v1/mpp/narrations/:id.
Returns {"id":"nar_…"}
POST /api/v1/mpp/episodes
Authenticated user who wants to pay per episode instead of subscribing. Creates an Episode attached to the user's primary podcast. Both credentials are required.
Returns {"id":"ep_…"}
Each of the three POST endpoints accepts the same source-and-metadata parameters. The differences are which Authorization scheme is required and whether the voice param is honored.
Path 1 — Bearer-authenticated with credits. The voice param is ignored; Episodes use the user's stored voice preference.
| Parameter | Type | Required | Description |
|---|---|---|---|
| source_type | string | Yes | One of "url", "text", "extension" |
| url | string | For url/extension | URL of the article |
| text | string | For text | Raw text to convert |
| content | string | For extension | HTML of the article body, typically Readability-extracted (not full page, not already-sanitized plain text). The server runs its own extractor; submissions are bounded by MPP_MAX_INPUT_CHARS (1,000,000). |
| title | string | No | Episode title (defaults to "Untitled") |
| author | string | No | Author name |
| description | string | No | Episode description |
"url", "text", "extension""Untitled")Path 2 — anonymous, Payment credential only. The voice param selects which voice (and therefore which tier price) the 402 challenge quotes.
| Parameter | Type | Required | Description |
|---|---|---|---|
| source_type | string | Yes | One of "url", "text", "extension" |
| url | string | For url/extension | URL of the article |
| text | string | For text | Raw text to convert |
| content | string | For extension | HTML of the article body, typically Readability-extracted (not full page, not already-sanitized plain text). The server runs its own extractor; submissions are bounded by MPP_MAX_INPUT_CHARS (1,000,000). |
| title | string | No | Episode title (defaults to "Untitled") |
| author | string | No | Author name |
| description | string | No | Episode description |
| voice | string | No | Voice catalog key. Defaults to "felix" (Standard). See Voices. |
"url", "text", "extension""Untitled")"felix" (Standard). See Voices.Path 3 — Bearer + Payment. An explicit voice param wins; otherwise the user's saved preference is used, falling back to the catalog default (felix, Standard).
| Parameter | Type | Required | Description |
|---|---|---|---|
| source_type | string | Yes | One of "url", "text", "extension" |
| url | string | For url/extension | URL of the article |
| text | string | For text | Raw text to convert |
| content | string | For extension | HTML of the article body, typically Readability-extracted (not full page, not already-sanitized plain text). The server runs its own extractor; submissions are bounded by MPP_MAX_INPUT_CHARS (1,000,000). |
| title | string | No | Episode title (defaults to "Untitled") |
| author | string | No | Author name |
| description | string | No | Episode description |
| voice | string | No | Voice catalog key. Overrides the user's stored voice preference for this request only. See Voices. |
"url", "text", "extension""Untitled")MPP callers may select a voice by passing a voice catalog key. All catalog voices are available on the MPP paths. The default when voice is omitted is felix (British male, Standard). Invalid keys return 422.
Voice resolution on MPP endpoints follows a three-step hierarchy: explicit voice param → user's saved preference (authenticated only) → catalog default (felix).
| Key | Name | Accent | Gender | Tier | Default |
|---|---|---|---|---|---|
| wren | Wren | British | Female | Standard | |
| felix | Felix | British | Male | Standard | ★ |
| sloane | Sloane | American | Female | Standard | |
| archer | Archer | American | Male | Standard | |
| gemma | Gemma | British | Female | Standard | |
| hugo | Hugo | British | Male | Standard | |
| quinn | Quinn | American | Female | Standard | |
| theo | Theo | American | Male | Standard | |
| elara | Elara | British | Female | Premium | |
| callum | Callum | British | Male | Premium | |
| lark | Lark | American | Female | Premium | |
| nash | Nash | American | Male | Premium |
★ default when voice is omitted
The narration price comes from the submission length and chosen voice with a minimum floor. The longer the text and the higher quality voice tier the more expensive the text-to-speech conversion. The 402 response's prices map quotes the exact tempo and stripe amounts for the request; pick a scheme and pay that scheme's amount. SPT (Stripe) prices are higher than Tempo because they absorb Stripe's per-redemption fee (2.9% + $0.30); Tempo on-chain has near-zero per-tx cost.
The credential is HMAC-bound to the priced character_count. Submissions longer than what was paid for get a fresh 402.
On podread.app, payments are settled in USDC.e — USDC bridged to Tempo via Stargate. It is not Circle-native USDC; token balances held on Tempo are bridge-wrapped and must be unwrapped via Stargate to redeem on other chains. Self-hosted deployments targeting Tempo's Moderato testnet can use the predeployed pathUSD stablecoin (0x20c0…0000) instead — the faucet dispenses pathUSD, not USDC.e. The request.currency field in every 402 challenge echoes whichever contract the server is configured for, so clients should read it rather than hardcode either address.
The prices map in the 402 response body is in cents. The tempo challenge's request.amount is in token base units; convert from cents with amount_cents × 10token_decimals / 100 (token decimals = 6 on USDC.e and pathUSD). The stripe challenge's request.amount is fiat cents-as-string.
A challenge is bound by an HMAC-signed request blob to a specific voice tier, character count, and method. A Standard-priced credential cannot be redeemed against a Premium voice (or vice versa); a credential issued for one input length cannot be reused at a larger length; a tempo-method credential cannot be redeemed for a stripe-method challenge — the server re-issues a fresh 402 at the correct price when anything mismatches. If narration processing fails after payment succeeds, the payment is automatically refunded.
A single submission cannot exceed 1,000,000 characters — pathological inputs are rejected with 413 Payload Too Large before any payment flow. The character ceiling is an abuse guardrail; legitimate long-form articles fit comfortably below it.
| Endpoint | Limit | Window | Key |
|---|---|---|---|
| POST /api/v1/episodes | 20 | 1 hour | Bearer token |
| POST /api/v1/mpp/narrations | 10 | 1 minute | IP address |
| POST /api/v1/mpp/episodes | 30 | 1 minute | Bearer token |
| GET /api/v1/mpp/narrations/:id | 60 | 1 minute | IP address |
Throttled requests return 429 Too Many Requests with a Retry-After header.
POST /api/v1/mpp/episodes is rate-limited at 30/minute per Bearer token. Same rationale as the narrations throttle — every 402 issuance has real cost (Stripe PaymentIntent, DB write, and for URL submissions an article fetch).
The anonymous MPP create limit (10/minute per IP) exists to block abusers who churn through the 402-challenge flow without paying — every challenge costs a Stripe PaymentIntent and a pending DB row. A legitimate client making one paid narration burns at most 2 requests (initial 402, retry with credential), so 10/min leaves 5× headroom.
| Status | Meaning | When |
|---|---|---|
| 201 | Created | Episode or narration created successfully |
| 400 | Bad Request | Malformed JSON body or missing required headers |
| 401 | Unauthorized | Bearer token is invalid, expired, or revoked |
| 402 | Payment Required | See the callout above — either an MPP challenge or an upgrade prompt |
| 403 | Forbidden | Authenticated user exists but lacks permission for the requested action |
| 404 | Not Found | Narration (GET /api/v1/mpp/narrations/:id) does not exist or has expired past its 24h TTL |
| 422 | Unprocessable Entity | Invalid or missing source_type, invalid voice, or content error |
| 429 | Too Many Requests | Rate limit exceeded (see Rate limits); includes a Retry-After header |
| 500 | Internal Server Error | Unexpected server failure (not caused by any of the above) |
| 503 | Service Unavailable | Stripe failed to provision a deposit address, or Tempo RPC is unreachable |
GET /api/v1/mpp/narrations/:id) does not exist or has expired past its 24h TTLsource_type, invalid voice, or content errorRetry-After headerFor self-hosting, the following environment variables configure MPP. Stripe must also be configured (standard STRIPE_SECRET_KEY etc.) since deposit addresses are provisioned via Stripe's crypto PaymentIntent API.
| Variable | Default | Description |
|---|---|---|
| MPP_SECRET_KEY | random hex | HMAC key for signing challenges and receipts. Must be stable across deploys. |
| MPP_CURRENCY | usd | Currency code |
| MPP_CHALLENGE_TTL_SECONDS | 300 | How long a 402 challenge is valid |
| MPP_MAX_INPUT_CHARS | 1000000 | Maximum input length per submission. Larger requests return 413. |
| MPP_STRIPE_API_VERSION | 2026-03-04.preview | Stripe API version pinned to MPP preview channel |
| TEMPO_RPC_URL | rpc.moderato.tempo.xyz | Tempo JSON-RPC endpoint |
| TEMPO_CURRENCY_TOKEN | 0x20c0…0000 | Stablecoin contract on Tempo. Default is pathUSD (testnet faucet-dispensable). podread.app overrides this to USDC.e at 0x20c0…b9537d11c60e8b50. |
| TEMPO_TOKEN_DECIMALS | 6 | Decimal places for the stablecoin (both pathUSD and USDC.e are 6) |
| TEMPO_RPC_OPEN_TIMEOUT_SECONDS | 5 | TCP connect timeout |
| TEMPO_RPC_READ_TIMEOUT_SECONDS | 10 | Read timeout |
| APP_HOST | localhost:3000 | Used as the realm in challenges |
0x20c0…b9537d11c60e8b50.