Buy Agent storage over HTTP
This page shows the full HTTP sequence to buy Agent storage when you have an Ed25519 account key and no CLI. It is for developers and AI agents. The page is self-contained. It covers the session, the capacity check, the quote, the payment, the result and the retry of the gated write. It ends with the route table, the discovery endpoints and the error envelope.
The host is https://mpp.uno.fun. Purchase routes refuse until storage offers open for your network. The live state is not checked here, so treat every step that needs a price as available only when the quote shows one.
What you need
- A 32-byte Ed25519 seed as 64 lowercase hex characters, in a private file. The matching public key is also 64 lowercase hex characters.
- Node 22 or later, to run the checked signing script. The script uses only Node built-ins.
curl. The commands below also usejqto read JSON. Any JSON reader works.- An MPP-capable wallet that can pay a Tempo charge. You need it only in the pay step.
The account key is your only identity while the account is unverified. A paying wallet grants no owner authority. Losing the key loses the account and its paid storage. Keep a backup outside disposable runtimes.
Source: session.md, purchase.md, llms.txt.
The sequence
- Get a Bearer session from your account key.
- Check capacity with
GET /v1/storage. - Request a quote with
POST /v1/purchases. The first call returns HTTP 402. - Pay the quote with an MPP wallet.
- Read the result with
GET /v1/purchases/{id}. - Retry the write that needed storage, with its original key and body.
Set the host once. Later commands also use $TOKEN, $PURCHASE_ID and $LINK, which you fill from earlier responses. The pay link is the last path segment before /pay in pay_url.
MPP=https://mpp.uno.funGet a Bearer session
A session takes two calls and one signature. The server stores nothing on the Worker. It returns a Kit-only credential. That credential is not an Uno session token and has no refresh token.
1. Read the current legal version
Read the terms and the version from /llms.txt. Agree to those terms before you ask the server to create an account.
curl -sS "$MPP/llms.txt" | grep -E '^(Terms|Privacy|Current legal acceptance version):'2. Request a challenge
Save the request as request.json. username is optional. If you send it, send it unchanged on both calls.
{
"public_key": "<64 lowercase hex characters>",
"legal_acceptance_version": "<version from llms.txt>"
}curl -sS -X POST "$MPP/v1/session/challenge" \
-H 'Content-Type: application/json' \
--data-binary @request.json > challenge.jsonThe response data holds challenge, issuer, audience, kit_audience, expires_at and signing_bytes. challenge is lowercase hex. signing_bytes is standard padded base64. An unused challenge lasts 120 seconds.
3. Check and sign the challenge
Download the checked script from $MPP/examples/sign-session.mjs. Run it with the request, the challenge and your seed file:
curl -sS -O "$MPP/examples/sign-session.mjs"
umask 077
node sign-session.mjs request.json challenge.json --key-file "$ACCOUNT_SEED_FILE" > session-proof.jsonTo read the seed from an environment variable, use --key-env ACCOUNT_SEED_HEX instead of --key-file. Name the variable. Never put its value on the command line.
If you cannot run Node, sign the bytes yourself. Decode signing_bytes and require exactly nine lines, joined by LF with no trailing LF:
| Line | Value |
|---|---|
| 1 | uno-account-key-kit-session |
| 2 | https://api.uno.fun (issuer) |
| 3 | https://mpp.uno.fun (audience) |
| 4 | https://kit.uno.fun (Kit audience) |
| 5 | The challenge from the response |
| 6 | Your public_key |
| 7 | Standard padded base64 of the UTF-8 username, empty when omitted |
| 8 | Standard padded base64 of the UTF-8 legal_acceptance_version |
| 9 | The expires_at from the response, as decimal Unix seconds |
Compare every line before you sign. Stop on any mismatch. Sign the exact decoded bytes with pure Ed25519. Do not hash them and do not serialize them as JSON. The output signature is standard padded base64. The script does all of this and refuses to sign on any mismatch.
The script prints the request fields plus challenge, expires_at and signature. Keep that output private.
4. Exchange the proof for a credential
Send the exact contents of session-proof.json:
curl -sS -X POST "$MPP/v1/session" \
-H 'Content-Type: application/json' \
--data-binary @session-proof.json > session.json
TOKEN=$(jq -r '.data.kit_token' session.json)A success has data.account_id, data.kit_token and data.credential_expires_at. The token lasts at most 300 seconds. Keep it private.
- If you lose the response, send the same proof body again. The server returns the original token and expiry. It does not renew them.
- If you get
PROOF_RESULT_EXPIRED, or the token ran out, request a fresh challenge with the same key and sign again. The account stays the same. - After the account is verified, the key stops signing in over HTTP. Use verified CLI or MCP sign-in to keep the same Agent plan.
Source: examples/session.md, examples/sign-session.mjs.
Check capacity
curl -sS "$MPP/v1/storage" -H "Authorization: Bearer $TOKEN"data.storage.plan holds state, capacity_bytes, expires_at, grace_ends_at and restore_cutoff_at. The state is one of AGENT_PLAN_STATE_NO_PLAN, AGENT_PLAN_STATE_ACTIVE, AGENT_PLAN_STATE_LAPSED, AGENT_PLAN_STATE_GRACE, AGENT_PLAN_STATE_SUSPENDED or AGENT_PLAN_STATE_GONE.
data.storage also holds used_bytes, reserved_bytes, retained_deleted_bytes, minimum_fit_bytes, kit_sizes and pricing. pricing states the unit price and whether offers are enabled. minimum_fit_bytes is the smallest capacity that holds what you store, plus reserved bytes, plus the refused write, rounded up to 100 MB. A smaller request is refused before any charge.
data.purchases[] lists open and paid purchases. Each entry carries purchase.purchase_id and pay_url. A fresh runtime with only the account key can find them here. Follow data.next_purchase_cursor with the query field purchase_cursor, and data.storage.kit_sizes.next_cursor with kit_cursor, for more pages.
A new Agent account may receive one promo grant on its first successful private write while campaign slots remain. If the grant was made, data.storage.promo_grant keeps its original capacity and expiry.
Source: examples/purchase.md.
Quote terms
| Term | Value |
|---|---|
| Capacity | Total storage including stored history, 100 MB to 10 TB, in exact 100 MB steps. MB, GB and TB are decimal. |
| Month | 30 days (2,592,000 seconds). |
| Price | USD 0.50 per GB-month, charged in the network token that the quote names. |
| Amount | An exact integer in token base units. Read asset_decimals. Never use floating point. |
| Months | months_to_add is a whole number. With no active plan it must be at least 1. With 0 you only raise capacity, and only while the plan has at least 30 minutes left. |
| Quote end | The earlier of 30 minutes after creation and the plan's next expiry or restore cutoff. |
| Refunds | None. A late, stale or duplicate payment becomes storage time, and the result says so. |
| Network | chain_id, asset and the recipient come from the quote. Pay only the quoted network and token. |
These values can change. The quote and the 402 headers are authoritative. Source: examples/purchase.md.
Get a quote
Send capacity_bytes and months_to_add as decimal strings. Add an Idempotency-Key. Use 1 to 128 characters from letters, digits, dot, underscore and hyphen. Keep the key and the exact body.
curl -i -X POST "$MPP/v1/purchases" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: public-test-buy-1' \
--data-binary '{"capacity_bytes":"1000000000","months_to_add":"1"}'The first call returns HTTP 402 with a WWW-Authenticate: Payment header. That header is the MPP payment challenge. The body is the error envelope:
- The
detailsentry of typeuno.kit.v1.PaymentChallengeholdsamountin whole tokens,currency,pay_urlandoffers. data.purchaseholds the frozen quote. It hasquote.amountin token base units,quote.chain_id,quote.asset,quote.capacity_bytes,quote.months_to_add,quote.usd_per_gb_month,quote.expires_atandquote.no_refund_statement.next_actionpoints at the pay URL.
Compare the amount, token, chain and capacity with what you intended. Get your user's approval for the exact amount before you pay. Store data.purchase.purchase_id.
While offers are off for your network, pay_url is empty, the quote shows no price, and the route refuses without a challenge. Stop and check again later.
Other outcomes of the same call:
- If the purchase is already paid, the call returns HTTP 200 with the immutable result in
data.purchase.purchase.results. It does not issue a new challenge. - If a payment is in flight, the call returns pending guidance with a poll
next_action. It never returns a second challenge.
For the MPP challenge format, see the Payment HTTP authentication scheme and the Tempo charge intent.
Pay
The payer must send an Authorization: Payment credential that answers the challenge. Build it with an MPP client or wallet. This page does not define the credential format.
Pay with the Tempo CLI
The Tempo CLI takes the exact pay_url, pays, and retries in one step. The values below are placeholders. --max-spend is the quoted amount in whole tokens. Use --network mainnet only when the quote's chain_id is the mainnet id.
tempo request --network testnet --payment-intent charge \
--max-spend 0.5 --payment-token 0xPUBLIC_TEST_TOKEN \
-X POST 'https://mpp.uno.fun/v1/purchases/PUBLIC_TEST_LINK/pay'Retry the quote request with a Payment credential
A wallet can instead retry the quote request itself. The retry uses the same URL and the same body. It carries no Bearer. The signed challenge pins the owner and the purchase. The server takes the purchase and quote from the verified challenge, not from your body.
POST /v1/purchases HTTP/1.1
Host: mpp.uno.fun
Authorization: Payment PUBLIC_TEST_PAYMENT_NOT_A_CREDENTIAL
Content-Type: application/json
Idempotency-Key: public-test-buy-1
{"capacity_bytes":"1000000000","months_to_add":"1"}A success is HTTP 200 with a Payment-Receipt header. data.purchase.status is PURCHASE_STATUS_SETTLED. data.purchase.results holds classification (quoted, stale, late or extra), applied_amount, capacity_bytes, starts_at, expires_at and payment_tx. Settlement time is when the writer applied the payment, not the chain time.
Never report storage as bought before PURCHASE_STATUS_SETTLED.
Pay as a separate payer
The owner sends the payer only the pay_url. The payer needs no account and sends no Bearer.
POST /v1/purchases/{link}/pay HTTP/1.1
Host: mpp.uno.fun
Authorization: Payment PUBLIC_TEST_PAYMENT_NOT_A_CREDENTIAL
Content-Type: application/json
{}A pay link grants payment only. It names the beneficiary account. It gives no ownership. The payer view shows terms, status and receipts, without usage, Kit sizes or credentials. The owner stays the acting account whoever pays. Any extra or late transfer still becomes storage time for the beneficiary.
Without a Payment credential, POST to the pay URL returns the 402 challenge and moves no money.
To confirm whom you fund, ask the owner for their account id with the pay_url, and send x-uno-expect-account: <account-id>. A pin that does not match the link's beneficiary gets HTTP 400 failed_precondition with reason ACCOUNT_MISMATCH before any charge. Stop and check the link.
Source: examples/purchase.md.
Read the result
Reading never pays. Check before you pay again after any lost response, crash or new runtime.
Owner read, with the Bearer:
curl -sS "$MPP/v1/purchases/$PURCHASE_ID" -H "Authorization: Bearer $TOKEN"This returns the stored purchase in data.owner.purchase, with quote, status and results. GET /v1/storage lists the same purchases from the account key alone.
Payer poll, with only the link and no credential:
curl -sS "$MPP/v1/purchases/$LINK/pay"This reconciles a pending payment against the network without broadcasting anything. It returns data.purchase. Wait next_action.delay_seconds, and honor the Retry-After header, between polls.
| What you see | What to do |
|---|---|
| Lost response to the quote | Replay POST /v1/purchases with the same key and body. You get the same purchase and quote, or the recorded result. Never use a new key to recover. |
| Quote ended before payment | Replay the same key and body. After the quote end you get a new quote under the same purchase. The replay never pays. |
| Pending | Poll GET /v1/purchases/{link}/pay. Never pay again while a payment may be in flight. |
| Settled | Read results. A replay returns the same result, with no second charge. |
| Different capacity or months under a used key | The request is refused as invalid_argument. Use a new key for a new purchase. |
| Expected-account mismatch | Stop. The call was pinned to a different account than the link or session names. |
Retry the gated write
A write never takes payment. A write that needs storage is refused, and you buy storage in a separate request. After PURCHASE_STATUS_SETTLED, retry the original write with its original Idempotency-Key and exact body. Renew the session first if the token expired.
This example is the inline commit from the contract:
curl -sS -X POST "$MPP/v1/kits/notes/commit" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: write-7' \
--data-binary '{"branch":"main","expected_head":"","message":"save","files":[{"path":"a.txt","text":"hello"}]}'A pending commit returns HTTP 202 with a job id and a poll next_action. Only a committed terminal job confirms that the commit landed.
Source: examples/purchase.md.
Routes
Credential classes: N needs none. O needs the Bearer from the session. P needs only the opaque pay link, plus a Payment credential to pay.
| Method and path | Class | Purpose |
|---|---|---|
GET /openapi.json | N | OpenAPI 3.1 for every served route |
GET /llms.txt | N | Agent route guide, terms URLs and legal version |
POST /v1/session/challenge | N | Get the bytes to sign |
POST /v1/session | N | Exchange a signed proof for a Kit credential |
GET /v1/storage | O | Storage snapshot and recorded purchases |
POST /v1/purchases | O, then Payment on retry | Create or recover a quote. Retry with Payment to pay |
GET /v1/purchases/{id} | O | Owner view of one purchase |
GET /v1/purchases/{link}/pay | P | Receipt-only poll. It never broadcasts |
POST /v1/purchases/{link}/pay | P, then Payment | Request the challenge, or submit payment |
GET /v1/purchases/{link}/pay sends no Authorization upstream, even if you supply one. The Worker serves other owner routes too. Read /openapi.json for the full list. Unregistered methods and paths return HTTP 404 with a recovery action.
Response headers
Payment routes pass these headers through from the backend, unchanged:
WWW-Authenticate. Every value is kept, including repeated values.Payment-Receipt.Retry-After.x-uno-expect-account,x-uno-expect-account-enforcedandx-uno-account-guard.
The Worker drops all other upstream headers. It never changes HTTP 402 to another status and never replaces a signed challenge.
Purchase responses have Cache-Control: no-store, private. Other Worker responses have Cache-Control: no-store.
Error envelope
Every success, pending response and error uses one JSON envelope.
| Field | Meaning |
|---|---|
code | A Connect error code such as not_found or resource_exhausted. null on success and on pending responses. |
reason | A stable machine value such as ROUTE_NOT_FOUND. Never infer a reason from message. |
message | Text for people. |
details | Typed details, decoded to snake_case JSON. A 402 carries uno.kit.v1.PaymentChallenge here. |
data | The result, with protobuf field names in snake_case. Timestamps are Unix seconds. |
receipt | Present on Kit writes, including refused and uncertain ones. state is refused, pending, complete or unknown. |
next_action | What to do next. action is follow, replay or stop. |
next_action has action, method, url, delay_seconds, idempotency_key and reuse_body. method and url are null only for stop. A replay repeats the original method, URL, key and body. Use stop when no unchanged retry can help.
A real example is the response to any unregistered route, as the Worker tests check it:
{
"code": "not_found",
"reason": "ROUTE_NOT_FOUND",
"message": "Route not found. Read /openapi.json for served routes.",
"details": [],
"data": null,
"next_action": {
"action": "follow",
"method": "GET",
"url": "/openapi.json",
"delay_seconds": 0,
"idempotency_key": null,
"reuse_body": false
}
}A refused Kit write keeps HTTP 403 and adds a receipt. The Worker tests check this refusal for a write with no paid term:
- Status
403,coderesource_exhausted,reasonAGENT_NO_PAID_TERM. receipt.stateisrefused.next_action.actionisstopandreuse_bodyisfalse.
Do not retry a stopped write unchanged. Buy storage, then retry with the original key and body.
The Worker keeps no state
The Worker has no database. It logs no request material, because request URLs can contain purchase links. Recovery relies on your Idempotency-Key, your body and the backend records, not on the Worker. Keep each key and body until you know the result.
Discovery
GET /openapi.json and GET /llms.txt describe every served route. On POST /v1/purchases and POST /v1/purchases/{link}/pay, /openapi.json carries this block:
"x-payment-info": { "method": "tempo", "intent": "charge", "amount": null }amount is null because the price comes from the quote, not from discovery. Read the amount from the 402 response.
Read the live /openapi.json for the complete list of routes and accepted inputs.