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
Premium elara, callum, lark, nash
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 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.

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

    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…",
      "voice_tier": "standard",
      "character_count": 1234
    }
    • 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 one-time Stripe-provisioned deposit address.

    • voice_tier string

      "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"
    }
    • amount string

      Fiat cents as a string — Stripe API convention. 150 = $1.50 (Standard SPT) and 250 = $2.50 (Premium SPT).

    • currency string

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

    • networkId string

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

    • voice_tier string

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

  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

    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 — 150 cents for Standard ($1.50), 250 for Premium ($2.50)
    • Target the networkId echoed in request.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…"
      }
    }

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

  5. Step 5

    Get audio

    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.

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"
  • 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
    Yes
    Description
    One of "url", "text", "extension"
  • 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"
  • 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

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

Token (podread.app)
USDC.e · 0x20c0…b9537d11c60e8b50
Decimals
6 (1 USD = 1,000,000)
RPC
rpc.moderato.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 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.

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

Error codes

  • Status
    201
    Meaning
    Created
    When
    Episode or narration created successfully
  • Status
    400
    Meaning
    Bad Request
    When
    Malformed JSON body or missing required headers
  • Status
    401
    Meaning
    Unauthorized
    When
    Bearer token is invalid, expired, or revoked
  • Status
    402
    Meaning
    Payment Required
    When
    See the callout above — either an MPP challenge or an upgrade prompt
  • Status
    403
    Meaning
    Forbidden
    When
    Authenticated user exists but lacks permission for the requested action
  • Status
    404
    Meaning
    Not Found
    When
    Narration (GET /api/v1/mpp/narrations/:id) does not exist or has expired past its 24h TTL
  • 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
    503
    Meaning
    Service Unavailable
    When
    Stripe failed to provision a deposit address, or Tempo RPC is unreachable

Environment

For 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
    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_STRIPE_API_VERSION
    Default
    2026-03-04.preview
    Description
    Stripe API version pinned to MPP preview channel
  • Variable
    TEMPO_RPC_URL
    Default
    rpc.moderato.tempo.xyz
    Description
    Tempo JSON-RPC endpoint
  • 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
    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.