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, tess, lachlan, priya, arjun |
| Premium | elara, callum, lark, nash, isla, reid, matilda, jarrah, meera, dev |
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 [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.
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…"
}
}
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:
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…",
"methodDetails": { "chainId": 4217 },
"voice_tier": "standard",
"character_count": 1234,
"nonce": "3f1a9c04b7e25d68"
}
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 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.
The Tempo chain the transfer must land on: 4217 for mainnet, 42431 for the Moderato testnet. Clients read it to pick the network.
"standard" or "premium". Part of the HMAC pre-image - swapping tiers between issuance and retry invalidates the credential.
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.
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"
}
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.
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 client; carried into the SPT redemption call.
Always ["card"]. mppx requires a non-empty list.
"standard" or "premium". Part of the HMAC pre-image - swapping tiers between issuance and retry invalidates the credential.
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.
16 random hex characters. It makes every challenge id unique, so two 402s issued in the same second never collide.
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 timestamptransferWithMemo, carrying the MPP attribution memo bound to the challenge id. The mppx client does this by default.
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 | Value | Meaning |
|---|---|---|
| 0-3 | keccak256("mpp")[0..3] | The MPP memo tag |
| 4 | 0x01 | The memo version |
| 5-14 | keccak256(realm)[0..9] | The realm fingerprint, for example podread.app |
| 15-24 | client id or zeros | The paying client. The server ignores these bytes. |
| 25-31 | keccak256(challenge id)[0..6] | The binding to one challenge |
podread.appFor 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. The server quotes that amount for this request, and the floor is 57 centsnetworkId echoed in request.methodDetails.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…"
}
}
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.
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>"}
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.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, 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.
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". Both episode endpoints reject a request without it. |
| 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". Both episode endpoints reject a request without it."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 | No | "url" or "text". Any other value, and an absent value, means "text". |
| 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" or "text". Any other value, and an absent value, means "text"."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". Both episode endpoints reject a request without it. |
| 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". Both episode endpoints reject a request without it."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 | |
| 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
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.
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.
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 (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.
| Status | Meaning | When |
|---|---|---|
| 201 | Created | Episode or narration created successfully |
| 401 | Unauthorized | Bearer token is invalid, expired, or revoked |
| 4xx | Error body | Every error except the MPP challenge is {"error":{"type","code","message","param"}}. The Authentication page lists the fields. |
| 402 | Payment Required | See the callout above - either an MPP challenge or an upgrade prompt |
| 404 | Not Found | Narration (GET /api/v1/mpp/narrations/:id) does not exist or has expired past its 24h TTL |
| 409 | Conflict | 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. |
| 413 | Payload Too Large | The input is longer than MPP_MAX_INPUT_CHARS. The server rejects it before any payment flow. |
| 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) |
| 502 | Bad Gateway | The server could not fetch the source URL (source_type=url) |
| 503 | Service Unavailable | Challenge issuance failed. Today that means TEMPO_DEPOSIT_ADDRESS is blank. An RPC failure at verify time returns a fresh 402, not a 503. |
{"error":{"type","code","message","param"}}. The Authentication page lists the fields.GET /api/v1/mpp/narrations/:id) does not exist or has expired past its 24h TTLPOST /api/v1/mpp/episodes): the credential already paid for an episode. A replayed narration credential returns 201 with the first narration id instead.MPP_MAX_INPUT_CHARS. The server rejects it before any payment flow.source_type, invalid voice, or content errorRetry-After headersource_type=url)TEMPO_DEPOSIT_ADDRESS is blank. An RPC failure at verify time returns a fresh 402, not a 503.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 | 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_TARGET_GROSS_MARGIN | 0.70 | Target gross margin the quote adds on top of the TTS and LLM cost. |
| MPP_MIN_NET_REVENUE_CENTS | 25 | Net revenue floor per request, which is also the tempo floor price. |
| MPP_LLM_OUTPUT_RATIO | 1.0 | Assumed LLM output characters per input character, used to estimate the cleanup cost. |
| STRIPE_API_VERSION | 2026-07-29.preview | Stripe API version pinned to MPP preview channel |
| STRIPE_NETWORK_ID | stripe_network_placeholder | Merchant Stripe Profile ID advertised as networkId in stripe challenges. Production must set the real one. |
| TEMPO_DEPOSIT_ADDRESS | none | Static Stripe crypto deposit address on Tempo. One per Stripe account. Required in production. |
| TEMPO_RPC_URL | env dependent | Tempo JSON-RPC endpoint. Defaults to https://rpc.tempo.xyz in production and https://rpc.moderato.tempo.xyz in every other environment. |
| 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 |
| MPP_CHAINID_GUARD | unset | Set to 1 to run the boot-time chain-id guard outside production, where it is always on. |
| TEMPO_SKIP_CHAINID_GUARD | unset | Set to 1 to skip the boot-time chain-id guard in any environment, production included. |
| APP_HOST | localhost:3000 | Used as the realm in challenges |
https://rpc.tempo.xyz in production and https://rpc.moderato.tempo.xyz in every other environment.0x20c0…b9537d11c60e8b50.