Docs

Public API
Copy page as Markdown

Turn any text into audio - pay per narration.

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, tess, lachlan, priya, arjun
Premium elara, callum, lark, nash, isla, reid, matilda, jarrah, meera, dev
Schemes
Tempo · SPT
Currency
USDC / USD
TTL
24 h

Quick start

The fastest path uses the mppx CLI. It handles the 402 challenge, on-chain payment, and credential construction automatically.

Pay podread.app on mainnet

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.

bash production - mainnet
# 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

Funding on mainnet

podread.app accepts payment in USDC.e (Stargate-bridged USDC) on Tempo at 0x20c0…b9537d11c60e8b50. Native USDC does not exist on Tempo today.

  1. Hold USDC on a chain Stargate supports (Base, Arbitrum, Optimism, and Polygon all work well).
  2. Bridge to Tempo via stargate.finance/bridge - USDC → USDC.e.
  3. Send the bridged USDC.e to the address printed by 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.

Try it on testnet

Run a self-hosted PodRead instance against Tempo's Moderato testnet. Faucet-dispensed pathUSD - no real money changes hands.

bash self-hosted - testnet
# Create an MPP wallet (one-time setup)
npx [email protected] account create --account tts-dev --network testnet

# Fund the wallet with testnet pathUSD
npx [email protected] account fund --account tts-dev --network testnet

# Pay a narration on your local server
MPPX_ACCOUNT=tts-dev npx [email protected] http://localhost:3000/api/v1/mpp/narrations \
  --network testnet -J '{"source_type":"text","text":"hello"}'

Pin the mppx version. --network testnet tells mppx which chain to sign for, and it must match methodDetails.chainId in the challenge request blob.

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 cost more than Standard for the same input. The 402 challenge quotes the exact price for your submission - see Pricing.

Other clients

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.

bash @stripe/link-cli - SPT
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

Protocol flow

Five steps: request, receive challenge, pay, retry with credential, poll for audio.

  1. Step 1

    Request episode creation

    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.

    response headers
    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"
    response body
    {
      "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…"
      }
    }

    Every 402 carries two parallel challenges, one per method, and the server stores a pending payment row for each. You pay one of them; a sweep job marks the other row failed after MPP_CHALLENGE_TTL_SECONDS.

    Fields in the outer challenge object:

    • id string

      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.

    • prices object

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

    • methods array

      Advertised payment methods, in the same order their per-method challenges appear in the WWW-Authenticate header. Always ["tempo", "stripe"] today.

    • currency string

      The ISO 4217 code ("usd"). Distinct from request.currency below, which is an ERC-20 contract address for tempo.

    • request string

      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…",
      "methodDetails": { "chainId": 4217 },
      "voice_tier": "standard",
      "character_count": 1234,
      "nonce": "3f1a9c04b7e25d68"
    }
    • amount string

      Token base units - convert from cents with amount_cents × 10token_decimals / 100. The stablecoin has 6 decimals.

    • currency string

      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.

    • recipient string

      The server's static Tempo deposit address. It is the same for every challenge, so the attribution memo is what binds a transfer to one challenge.

    • methodDetails.chainId integer

      The Tempo chain the transfer must land on: 4217 for mainnet, 42431 for the Moderato testnet. Clients read it to pick the network.

    • voice_tier string

      "standard" or "premium". Part of the HMAC pre-image - swapping tiers between issuance and retry invalidates the credential.

    • character_count integer

      The character count the server priced. It is HMAC-bound, so a retry with a larger body gets a fresh 402 at the new price.

    • nonce string

      16 random hex characters. It makes every challenge id unique, so two 402s issued in the same second never collide.

    The stripe challenge encodes the fiat-cents amount and the merchant's networkId (Stripe Profile ID), with no on-chain recipient:

    {
      "amount": "57",
      "currency": "usd",
      "methodDetails": {
        "networkId": "profile_61UbsYpl4SxclGNZQA6UbsYpSGSQ2OwdqYukbeHlQOwS",
        "paymentMethodTypes": ["card"]
      },
      "voice_tier": "standard",
      "character_count": 1234,
      "nonce": "8c42e0b71d9f5a36"
    }
    • amount string

      Fiat cents as a string - Stripe API convention. The server quotes this amount for this request, and the client must pay exactly it. The stripe floor is 57 cents.

    • currency string

      ISO 4217 code ("usd"). Same value as the outer challenge's currency.

    • methodDetails.networkId string

      The merchant's Stripe Profile ID, profile_61Ubs… for podread.app. Required by mppx's stripe client; carried into the SPT redemption call.

    • methodDetails.paymentMethodTypes array

      Always ["card"]. mppx requires a non-empty list.

    • voice_tier string

      "standard" or "premium". Part of the HMAC pre-image - swapping tiers between issuance and retry invalidates the credential.

    • character_count integer

      The character count the server priced. It is HMAC-bound, so a retry with a larger body gets a fresh 402 at the new price.

    • nonce string

      16 random hex characters. It makes every challenge id unique, so two 402s issued in the same second never collide.

  2. Step 2

    Pay (tempo) - on-chain transfer

    Send a USDC transfer on the Tempo network to the deposit_address from the challenge. The transfer must be:

    • To the exact deposit_address returned in the 402 response
    • For the exact request.amount from the tempo challenge (token base units; convert from cents with amount_cents × 106 / 100)
    • Of the stablecoin whose contract address is echoed in the challenge's request.currency field - on podread.app that is USDC.e (Stargate-bridged USDC) at 0x20c0…b9537d11c60e8b50
    • Completed before the challenge expires timestamp
    • Sent with TIP-20 transferWithMemo, carrying the MPP attribution memo bound to the challenge id. The mppx client does this by default.

    Attribution memo layout

    A client other than mppx must build the memo itself. The server looks for a TIP-20 TransferWithMemo event with topic 0x57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0 in the transaction receipt. A plain ERC-20 Transfer of the right amount to the right address is rejected, because nothing binds it to a challenge.

    The memo is one bytes32 word with this layout:

    • Bytes
      0-3
      Value
      keccak256("mpp")[0..3]
      Meaning
      The MPP memo tag
    • Bytes
      4
      Value
      0x01
      Meaning
      The memo version
    • Bytes
      5-14
      Value
      keccak256(realm)[0..9]
      Meaning
      The realm fingerprint, for example podread.app
    • Bytes
      15-24
      Value
      client id or zeros
      Meaning
      The paying client. The server ignores these bytes.
    • Bytes
      25-31
      Value
      keccak256(challenge id)[0..6]
      Meaning
      The binding to one challenge

    Pay (stripe) - Link shared payment token

    For 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:

    • Authorize the exact request.amount from the stripe challenge. The server quotes that amount for this request, and the floor is 57 cents
    • Target the networkId echoed in request.methodDetails.networkId (the merchant's Stripe Profile ID)
    • Be redeemable before the challenge expires timestamp

    The @stripe/link-cli walkthrough covers this end-to-end with copy-paste commands.

  3. Step 3

    Retry with credential

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

    A transaction hash is single use. When a hash has already paid for a challenge, the server issues a fresh 402 rather than a 409.

    Both payload paths can block. The server polls the Tempo RPC for the transaction receipt for up to about 10 seconds, so hold the HTTP connection open that long.

    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.

    Every SPT failure looks the same from outside. An idempotent replay of a spent token, a token that needs more action from the cardholder, and a card decline all return a fresh 402 with no reason field. Read the Stripe side of the flow in your Link app or Stripe dashboard to tell them apart.

    Canonical JSON

    The HMAC covers the request blob as compact JSON with the keys sorted at every level. Echo the request string back unchanged and you never have to think about this. A client that rebuilds the blob must sort the keys the same way, or the challenge id no longer matches. The server accepts standard or url-safe base64, with or without padding.

    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:

    {
      "object": "narration",
      "id": "nar_aB3xK9mQ2pL7",
      "status": "pending",
      "title": "The article title",
      "author": "Jane Doe",
      "duration_seconds": null,
      "audio_url": null
    }
    
    // Payment-Receipt header (unpadded url-safe base64 of):
    // {"method":"tempo","reference":"0xabc123…","status":"success",
    //  "timestamp":"2026-09-13T16:12:53Z","payment":"mpp_…","sig":"<hmac-hex>"}
    • The body is the created object. object is narration (from /api/v1/mpp/narrations) or episode (from /api/v1/mpp/episodes), and id is its public identifier with the matching nar_ or ep_ prefix. An episode body has the fields of GET /api/v1/episodes/:id.
    • Payment-Receipt is the mppx receipt format: unpadded url-safe base64 JSON. method is tempo or stripe, reference is the on-chain transaction hash or the Stripe PaymentIntent id, status is always success, and timestamp is RFC 3339. Two extra keys: payment is the opaque MppPayment public id (useful for support inquiries), and sig is an HMAC-SHA256 over the string reference|payment keyed with the server's secret. The key never leaves the server, so only the server can verify sig. Treat it as a support token, not as a proof you can check yourself.
  4. Step 4

    Poll for status

    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.

  5. Step 5

    Get audio

    When status is complete, audio_url holds the signed link. Before that it is null:

    {
      "object": "narration",
      "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.

Authentication paths

There are three POST endpoints, one per intent. The endpoint you call determines what credentials the server demands and what resource it creates.

Path 1

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.

Authorization:
Bearer <token>

Returns {"id":"ep_…"}

Path 2

Anonymous MPP

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.

Authorization:
Payment <credential>

Returns {"id":"nar_…"}

Path 3

Authenticated MPP

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.

Authorization:
Bearer <token>,
Payment <credential>

Returns {"id":"ep_…"}

Request reference

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.

POST /api/v1/episodes

Path 1 - Bearer-authenticated with credits. The voice param is ignored; Episodes use the user's stored voice preference.

  • Parameter
    source_type
    Type
    string
    Required
    Yes
    Description
    One of "url", "text", "extension". Both episode endpoints reject a request without it.
  • Parameter
    url
    Type
    string
    Required
    For url/extension
    Description
    URL of the article
  • Parameter
    text
    Type
    string
    Required
    For text
    Description
    Raw text to convert
  • Parameter
    content
    Type
    string
    Required
    For extension
    Description
    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).
  • Parameter
    title
    Type
    string
    Required
    No
    Description
    Episode title (defaults to "Untitled")
  • Parameter
    author
    Type
    string
    Required
    No
    Description
    Author name
  • Parameter
    description
    Type
    string
    Required
    No
    Description
    Episode description

POST /api/v1/mpp/narrations

Path 2 - anonymous, Payment credential only. The voice param selects which voice (and therefore which tier price) the 402 challenge quotes.

  • Parameter
    source_type
    Type
    string
    Required
    No
    Description
    "url" or "text". Any other value, and an absent value, means "text".
  • Parameter
    url
    Type
    string
    Required
    For url/extension
    Description
    URL of the article
  • Parameter
    text
    Type
    string
    Required
    For text
    Description
    Raw text to convert
  • Parameter
    content
    Type
    string
    Required
    For extension
    Description
    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).
  • Parameter
    title
    Type
    string
    Required
    No
    Description
    Episode title (defaults to "Untitled")
  • Parameter
    author
    Type
    string
    Required
    No
    Description
    Author name
  • Parameter
    description
    Type
    string
    Required
    No
    Description
    Episode description
  • Parameter
    voice
    Type
    string
    Required
    No
    Description
    Voice catalog key. Defaults to "felix" (Standard). See Voices.

POST /api/v1/mpp/episodes

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
    source_type
    Type
    string
    Required
    Yes
    Description
    One of "url", "text", "extension". Both episode endpoints reject a request without it.
  • Parameter
    url
    Type
    string
    Required
    For url/extension
    Description
    URL of the article
  • Parameter
    text
    Type
    string
    Required
    For text
    Description
    Raw text to convert
  • Parameter
    content
    Type
    string
    Required
    For extension
    Description
    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).
  • Parameter
    title
    Type
    string
    Required
    No
    Description
    Episode title (defaults to "Untitled")
  • Parameter
    author
    Type
    string
    Required
    No
    Description
    Author name
  • Parameter
    description
    Type
    string
    Required
    No
    Description
    Episode description
  • Parameter
    voice
    Type
    string
    Required
    No
    Description
    Voice catalog key. Overrides the user's stored voice preference for this request only. See Voices.

Voices

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
tess Tess Australian Female Standard
lachlan Lachlan Australian Male Standard
priya Priya Indian Female Standard
arjun Arjun Indian Male Standard
isla Isla British Female Premium
reid Reid American Male Premium
matilda Matilda Australian Female Premium
jarrah Jarrah Australian Male Premium
meera Meera Indian Female Premium
dev Dev Indian Male Premium

★ default when voice is omitted

Pricing

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. Shared Payment Tokens (SPT via Stripe) cost more than Tempo, because credit card processing is more expensive than stablecoin.

The price is locked to the length we quoted. A request with more text than you paid for gets a new 402 with a new price.

You get the article we priced. When you ask for a price on a url, PodRead saves the article text it fetched. The narration uses that saved text. If the page changes after the quote, your narration does not.

Send the same input you got the price for. A price for a url pays for that URL. A price for text pays for text up to the character count you paid for. A request with another input gets a new 402 with a new price. PodRead does not charge the credential. If you already sent a tempo transfer, PodRead refunds it.

Token (podread.app)
USDC.e · 0x20c0…b9537d11c60e8b50
Decimals
6 (1 USD = 1,000,000)
RPC (podread.app)
https://rpc.tempo.xyz

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 the other way around. 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.

A mismatch like this costs the caller nothing in most cases, because the server checks it before any money moves. A refused stripe credential is never charged, and the caller can retry the same credential with a corrected request. A refused tempo credential with a signature payload is never broadcast, so it spends no gas. That caller can also retry the same credential.

A refused tempo credential with a hash payload is different, because the transfer already happened on-chain before the request arrived. The server verifies that transfer, then refunds it. The refund spends the credential, so a retry of it fails. The fresh 402 tells the caller to pay again, and the refund brings the old money back - see the lag note below.

If narration processing fails after payment succeeds, the payment is automatically refunded.

A tempo refund can lag. The server records each settled on-chain payment as a Stripe PaymentIntent a few seconds after settlement, and a refund needs that PaymentIntent. A refund requested before it exists is flagged on the payment row, then replayed once the PaymentIntent lands.

Input limits

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.

Rate limits

  • Endpoint
    POST /api/v1/episodes
    Limit
    20
    Window
    1 hour
    Key
    Bearer token
  • Endpoint
    POST /api/v1/mpp/narrations
    Limit
    10
    Window
    1 minute
    Key
    IP address
  • Endpoint
    POST /api/v1/mpp/episodes
    Limit
    30
    Window
    1 minute
    Key
    Bearer token
  • Endpoint
    GET /api/v1/mpp/narrations/:id
    Limit
    60
    Window
    1 minute
    Key
    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 (a 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 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.

Error codes

  • Status
    201
    Meaning
    Created
    When
    Episode or narration created successfully
  • Status
    401
    Meaning
    Unauthorized
    When
    Bearer token is invalid, expired, or revoked
  • Status
    4xx
    Meaning
    Error body
    When
    Every error except the MPP challenge is {"error":{"type","code","message","param"}}. The Authentication page lists the fields.
  • Status
    402
    Meaning
    Payment Required
    When
    See the callout above - either an MPP challenge or an upgrade prompt
  • Status
    404
    Meaning
    Not Found
    When
    Narration (GET /api/v1/mpp/narrations/:id) does not exist or has expired past its 24h TTL
  • Status
    409
    Meaning
    Conflict
    When
    Episodes only (POST /api/v1/mpp/episodes): the credential already paid for an episode. A replayed narration credential returns 201 with the first narration id instead.
  • Status
    413
    Meaning
    Payload Too Large
    When
    The input is longer than MPP_MAX_INPUT_CHARS. The server rejects it before any payment flow.
  • Status
    422
    Meaning
    Unprocessable Entity
    When
    Invalid or missing source_type, invalid voice, or content error
  • Status
    429
    Meaning
    Too Many Requests
    When
    Rate limit exceeded (see Rate limits); includes a Retry-After header
  • Status
    500
    Meaning
    Internal Server Error
    When
    Unexpected server failure (not caused by any of the above)
  • Status
    502
    Meaning
    Bad Gateway
    When
    The server could not fetch the source URL (source_type=url)
  • Status
    503
    Meaning
    Service Unavailable
    When
    Challenge issuance failed. Today that means TEMPO_DEPOSIT_ADDRESS is blank. An RPC failure at verify time returns a fresh 402, not a 503.

Environment

For self-hosting, the following environment variables configure MPP. Stripe must also be configured (standard STRIPE_SECRET_KEY etc.) because the server records each settled on-chain payment as a Stripe PaymentIntent.

  • Variable
    MPP_SECRET_KEY
    Default
    random hex
    Description
    HMAC key for signing challenges and receipts. Must be stable across deploys.
  • Variable
    MPP_CURRENCY
    Default
    usd
    Description
    Currency code
  • Variable
    MPP_CHALLENGE_TTL_SECONDS
    Default
    300
    Description
    How long a 402 challenge is valid
  • Variable
    MPP_MAX_INPUT_CHARS
    Default
    1000000
    Description
    Maximum input length per submission. Larger requests return 413.
  • Variable
    MPP_TARGET_GROSS_MARGIN
    Default
    0.70
    Description
    Target gross margin the quote adds on top of the TTS and LLM cost.
  • Variable
    MPP_MIN_NET_REVENUE_CENTS
    Default
    25
    Description
    Net revenue floor per request, which is also the tempo floor price.
  • Variable
    MPP_LLM_OUTPUT_RATIO
    Default
    1.0
    Description
    Assumed LLM output characters per input character, used to estimate the cleanup cost.
  • Variable
    STRIPE_API_VERSION
    Default
    2026-07-29.preview
    Description
    Stripe API version pinned to MPP preview channel
  • Variable
    STRIPE_NETWORK_ID
    Default
    stripe_network_placeholder
    Description
    Merchant Stripe Profile ID advertised as networkId in stripe challenges. Production must set the real one.
  • Variable
    TEMPO_DEPOSIT_ADDRESS
    Default
    none
    Description
    Static Stripe crypto deposit address on Tempo. One per Stripe account. Required in production.
  • Variable
    TEMPO_RPC_URL
    Default
    env dependent
    Description
    Tempo JSON-RPC endpoint. Defaults to https://rpc.tempo.xyz in production and https://rpc.moderato.tempo.xyz in every other environment.
  • Variable
    TEMPO_CURRENCY_TOKEN
    Default
    0x20c0…0000
    Description
    Stablecoin contract on Tempo. Default is pathUSD (testnet faucet-dispensable). podread.app overrides this to USDC.e at 0x20c0…b9537d11c60e8b50.
  • Variable
    TEMPO_TOKEN_DECIMALS
    Default
    6
    Description
    Decimal places for the stablecoin (both pathUSD and USDC.e are 6)
  • Variable
    TEMPO_RPC_OPEN_TIMEOUT_SECONDS
    Default
    5
    Description
    TCP connect timeout
  • Variable
    TEMPO_RPC_READ_TIMEOUT_SECONDS
    Default
    10
    Description
    Read timeout
  • Variable
    MPP_CHAINID_GUARD
    Default
    unset
    Description
    Set to 1 to run the boot-time chain-id guard outside production, where it is always on.
  • Variable
    TEMPO_SKIP_CHAINID_GUARD
    Default
    unset
    Description
    Set to 1 to skip the boot-time chain-id guard in any environment, production included.
  • Variable
    APP_HOST
    Default
    localhost:3000
    Description
    Used as the realm in challenges

Start listening free

2 episodes/month, no credit card required

By signing up, you agree to our Terms and Privacy Policy.