Docs
link-cli --help if a command here does not match your build.
Pay PodRead with @stripe/link-cli
The MPP API reference documents the protocol; this page walks through the eight commands that take you from an empty terminal to a paid narration using @stripe/link-cli and a Stripe Link wallet. No on-chain wallet, no Tempo bridging - just a Link account and a Node.js install.
PodRead's MPP endpoints advertise both tempo and stripe challenges in the same 402 response. link-cli reads the stripe-method challenge, mints a Stripe shared payment token (SPT) you approve in the Link app, and replays the request with the credential.
1. Install link-cli
Install globally so the link-cli binary is on your PATH.
npm i -g @stripe/link-cli
link-cli --version
2. Authenticate
Pair the CLI with your Stripe Link account. The --client-name label is what you'll see when approving requests in the Link app - pick something you'll recognize.
link-cli auth login --client-name podread-cli
A device-pairing flow opens - confirm in the Link app to finish.
3. List payment methods
List the cards/bank accounts attached to your Link wallet and copy the id of the one you'll spend from.
link-cli payment-methods list
4. Probe for the challenge
Hit the endpoint without a credential to read the 402 challenge. The WWW-Authenticate header carries both methods; you want the stripe one's networkId and amount. Fetch it with curl, then feed the header value into link-cli mpp decode --challenge to extract the structured fields.
# Step 1 - capture the WWW-Authenticate header from a 402 response
CHALLENGE=$(curl -s -i -X POST https://podread.app/api/v1/mpp/narrations \
-H "Content-Type: application/json" \
-d '{"source_type":"text","text":"Hello, world.","voice":"felix","title":"Hello"}' \
| grep -i '^www-authenticate:' \
| sed 's/^www-authenticate: //i' | tr -d '\r')
# Step 2 - let link-cli pick out the stripe-method fields
link-cli mpp decode --challenge "$CHALLENGE"
Decode prints network_id, the amount in cents (under request_json.amount), and the challenge id/expires. The --challenge flag takes the raw header value - single-quote it or capture it into a shell variable first; embedded commas and equals signs will otherwise confuse the shell.
The stripe-method challenge will carry PodRead's networkId - profile_61UbsYpl4SxclGNZQA6UbsYpSGSQ2OwdqYukbeHlQOwS - and an amount in cents. The server quotes that amount for this one request from the input length and the voice tier, so read it rather than hardcode it (see Pricing below).
Put the quoted amount in a shell variable now. Step 5 passes it straight through. The snippet below keeps the stripe-method challenge, decodes its base64 request blob, and reads amount with jq.
# Step 3: read the quote out of the stripe-method challenge
AMOUNT=$(printf '%s' "$CHALLENGE" \
| awk '{ gsub(/Payment /, "\n"); print }' \
| grep 'method="stripe"' \
| sed -n 's/.*request="\([^"]*\)".*/\1/p' \
| base64 -d | jq -r .amount)
echo "$AMOUNT" # cents, for example 57
5. Create a spend request
A spend request is the SPT precursor - it tells Stripe "I want to authorize this much from this payment method to this merchant network." The --amount must match the stripe challenge from Step 4 exactly, so pass the $AMOUNT you read there. Prices are per request, not per tier, so a literal amount copied from another run will be wrong - see Pricing.
link-cli spend-request create \
--credential-type shared_payment_token \
--network-id profile_61UbsYpl4SxclGNZQA6UbsYpSGSQ2OwdqYukbeHlQOwS \
--amount "$AMOUNT" \
--currency usd \
--payment-method-id 'csmrpd_*ABC123' \
--context "PodRead narration synthesis (Standard tier / Felix voice). One-time charge for an MP3 audio narration." \
--line-item "name:Standard narration (Felix),quantity:1,unit_amount:$AMOUNT" \
--total "type:total,display_text:Standard narration,amount:$AMOUNT" \
--request-approval
A few flag notes the CLI's --help doesn't make obvious:
--payment-method-id(not--payment-method). Passing the wrong name fails withUnknown flag.--contextis required and must be at least 100 characters. This text is what your Link app shows on the approval card; write it for a human.-
--line-itemand--totaleach take a single string of comma-separatedkey:valuepairs (one flag invocation = one item). Repeating the flag does not set fields on a single item - it creates separate items. Schema:--line-item:name(required),quantity,unit_amount(cents),description,sku,product_url,image_url.--total:type,display_text,amount(cents) - all required.
- The parser splits values on
,as well as keys, so values cannot contain commas. Pick comma-freename/display_textstrings (e.g."Standard narration (Felix)"instead of"Felix, Standard"). --request-approvalpushes the request to your Link app and starts polling. Omit it and the spend request stays increatedforever.
Note the resulting lsrq_… id - you'll pass it back in Step 7.
6. Approve in the Link app
Open the Link app on your phone. You should see a pending approval matching the --client-name you set in Step 2 - the --context text and line items from Step 5 render on the approval card. Tap to approve.
The spend-request create --request-approval call from Step 5 polls in the background and prints the final object once Stripe propagates the approval - usually within a couple of seconds. If you closed that terminal, poll manually:
# Compact polling - prints just the status string each tick
link-cli spend-request retrieve 'lsrq_1TSkEkHPzSsifbC1VtmAFGyD' \
--interval 2 --max-attempts 150 \
--format json --filter-output status
--filter-output status --format json reduces each poll tick to a single ["pending_approval"] / ["approved"] line instead of re-dumping the whole object - useful when screen-recording the flow. Drop those flags if you want to see the full approval card payload.
Once status is approved, the SPT is mintable for that spend request. link-cli mpp pay in the next step does the actual minting + redemption against PodRead.
7. Pay the endpoint
link-cli mpp pay mints an SPT against the approved spend request, builds the credential (base64url JSON of {challenge, payload: {spt}}), and replays the request with Authorization: Payment …. On success PodRead returns 201 with a nar_… id and a Payment-Receipt header.
Real narration payloads are multi-KB article text with paragraphs, quotes, and apostrophes - well past anything you want to inline as a single shell-quoted string. --data takes a string only (no @file magic), so write the JSON to a file first and feed it in via shell command-substitution:
# 1. Build the request body in a file (any editor; here, a heredoc)
cat > narration.json <<'JSON'
{
"source_type": "text",
"text": "The first principle is that you must not fool yourself, and you are the easiest person to fool. Richard Feynman, 1974, said that to a graduating class at Caltech. He was talking about cargo-cult science, but the line generalizes: it is the same warning every careful operator has had to issue to themselves at some point. Verify, then trust. Reproduce, then publish. The world owes us nothing in the way of confirmation; we have to go and check.",
"voice": "felix",
"title": "Cargo cult"
}
JSON
# 2. Pay - note the $(cat …) substitution and the single quotes around the lsrq id
link-cli mpp pay https://podread.app/api/v1/mpp/narrations \
--spend-request-id 'lsrq_1TSkEkHPzSsifbC1VtmAFGyD' \
--method POST \
--data "$(cat narration.json)" \
--format json
Use "$(cat …)", not '$(cat …)' - single quotes in shell suppress substitution. The double-quoted form preserves the JSON exactly while letting $(…) expand. The response body will look like:
{
"object": "narration",
"id": "nar_aB3xK9mQ2pL7",
"status": "pending",
"title": "Cargo cult",
"author": null,
"duration_seconds": null,
"audio_url": null
}
8. Poll for audio
Narrations synthesize asynchronously. Poll the show endpoint until audio_url appears (typically 10–30 seconds for Standard, longer for Premium / long inputs).
curl https://podread.app/api/v1/mpp/narrations/nar_aB3xK9mQ2pL7
Status progresses through pending → preparing → processing → complete. Once complete the response includes a signed audio_url good for 5 minutes; download the mp3 before it expires. Anonymous narrations are garbage-collected 24 hours after creation.
Pricing
There is no price list. The server quotes each request in its 402 challenge, from the length of the submission and the tier of the voice. Longer text costs more, and a Premium voice costs more than a Standard one for the same text. The floor per request is 25 cents on tempo and 57 cents on stripe.
The stripe quote sits above the tempo quote, because credit card processing is more expensive than stablecoin. Pass the lower-case voice key (e.g. felix) as voice in the request body. The server resolves the tier and prices the request.
Voice catalog
| 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
Matching the amount
The --amount you pass to spend-request create must match the stripe-method challenge from Step 4 exactly. The amount is not client-supplied: the server charges the SPT for the amount it quoted. A wrong amount fails at Stripe redemption, and the server answers with a fresh 402 instead of the narration.
See the MPP API reference Pricing section for the full per-scheme breakdown.