Skip to content
Uno Docs

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 use jq to 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

  1. Get a Bearer session from your account key.
  2. Check capacity with GET /v1/storage.
  3. Request a quote with POST /v1/purchases. The first call returns HTTP 402.
  4. Pay the quote with an MPP wallet.
  5. Read the result with GET /v1/purchases/{id}.
  6. 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.fun

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

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

The 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.json

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

LineValue
1uno-account-key-kit-session
2https://api.uno.fun (issuer)
3https://mpp.uno.fun (audience)
4https://kit.uno.fun (Kit audience)
5The challenge from the response
6Your public_key
7Standard padded base64 of the UTF-8 username, empty when omitted
8Standard padded base64 of the UTF-8 legal_acceptance_version
9The 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

TermValue
CapacityTotal storage including stored history, 100 MB to 10 TB, in exact 100 MB steps. MB, GB and TB are decimal.
Month30 days (2,592,000 seconds).
PriceUSD 0.50 per GB-month, charged in the network token that the quote names.
AmountAn exact integer in token base units. Read asset_decimals. Never use floating point.
Monthsmonths_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 endThe earlier of 30 minutes after creation and the plan's next expiry or restore cutoff.
RefundsNone. A late, stale or duplicate payment becomes storage time, and the result says so.
Networkchain_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 details entry of type uno.kit.v1.PaymentChallenge holds amount in whole tokens, currency, pay_url and offers.
  • data.purchase holds the frozen quote. It has quote.amount in token base units, quote.chain_id, quote.asset, quote.capacity_bytes, quote.months_to_add, quote.usd_per_gb_month, quote.expires_at and quote.no_refund_statement.
  • next_action points 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 seeWhat to do
Lost response to the quoteReplay 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 paymentReplay the same key and body. After the quote end you get a new quote under the same purchase. The replay never pays.
PendingPoll GET /v1/purchases/{link}/pay. Never pay again while a payment may be in flight.
SettledRead results. A replay returns the same result, with no second charge.
Different capacity or months under a used keyThe request is refused as invalid_argument. Use a new key for a new purchase.
Expected-account mismatchStop. 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 pathClassPurpose
GET /openapi.jsonNOpenAPI 3.1 for every served route
GET /llms.txtNAgent route guide, terms URLs and legal version
POST /v1/session/challengeNGet the bytes to sign
POST /v1/sessionNExchange a signed proof for a Kit credential
GET /v1/storageOStorage snapshot and recorded purchases
POST /v1/purchasesO, then Payment on retryCreate or recover a quote. Retry with Payment to pay
GET /v1/purchases/{id}OOwner view of one purchase
GET /v1/purchases/{link}/payPReceipt-only poll. It never broadcasts
POST /v1/purchases/{link}/payP, then PaymentRequest 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-enforced and x-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.

FieldMeaning
codeA Connect error code such as not_found or resource_exhausted. null on success and on pending responses.
reasonA stable machine value such as ROUTE_NOT_FOUND. Never infer a reason from message.
messageText for people.
detailsTyped details, decoded to snake_case JSON. A 402 carries uno.kit.v1.PaymentChallenge here.
dataThe result, with protobuf field names in snake_case. Timestamps are Unix seconds.
receiptPresent on Kit writes, including refused and uncertain ones. state is refused, pending, complete or unknown.
next_actionWhat 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, code resource_exhausted, reason AGENT_NO_PAID_TERM.
  • receipt.state is refused.
  • next_action.action is stop and reuse_body is false.

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.