Streamlivr: agents buy creator data on Celo
Streamlivr is a live-streaming and music app. Creators publish profiles, posts and tracks. Thisrepository is the payment layer that sells that public data to software, one HTTP request at a time,over x402 on Celo. Money that arrives gets attributed to the creators whose data was served, andpayouts leave from the Celo wallet the settlements landed in.
Five paid routes: one cent per credited creator on data pages, with a one-cent minimum; ping andsingle-profile requests cost one cent. Payments settle in USDC through the hosted Celo facilitator. Thebuyer signs an EIP-3009 authorization off-chain, retries the request, and the facilitator pays thegas. A buyer never needs CELO.
The production service runs inside Streamlivr's private repositories. This repo carries the samepayment modules, a runnable reference server, and a browser demo site that pays real mainnetinvoices.
Submitted to the Celo Agents at Work hackathon. Primary track: real-world adoption. Additionaltrack: Judges' Favorite.
Start here: the demo site
demo/web is a Next.js checkout that pays the live API from a burner wallet in your browser. Oneclick runs the whole loop: quote, signature, settlement, and a transaction link you can open onCelo Blockscout. This is the fastest way to see the system work.
cd demo/web/web
cp .env.example .env.local
Set five values in .env.local:
NEXT_PUBLIC_API_BASE_URL=https://api.streamlivr.com
NEXT_PUBLIC_BURNER_PRIVATE_KEY=0x... # a throwaway key you fund
NEXT_PUBLIC_ENABLE_MAINNET=true # false keeps the page on Celo Sepolia
NEXT_PUBLIC_MAX_ATOMIC_PER_REQUEST=1000000 # $1.00, checked before anything is signed
NEXT_PUBLIC_MAX_ATOMIC_PER_SESSION=5000000 # $5.00 for one browser session
npm install
npm run dev # http://localhost:3008
The page opens on Celo mainnet. The suggestion chips are read live from the seller's free inventoryroute, so they name tags and countries that are actually in the catalogue.
You can also type a sentence. "who is making amapiano in Lagos" and "what does a page cost" go toPOST /api/v1/agent/chat/interpret, a free route that reads the sentence with the same languagemodel the Streamlivr app uses for its support chat. The route returns one call, the page makes itwith the burner wallet, and the conversation stays in the browser. The model key stays on theserver. Run the demo without one and the page reads the sentence itself, so the chat works eitherway.
The chat first probes the route and shows the invoice it got back, then signs the authorization inthe background and repeats the call with the payment header. Nothing pops up, because the burner keylives in the bundle. The reply arrives with the settlement transaction hash, and Payout ledger inthe header shows the same payment split 60/40 between the creators and the platform. On chain thatsettlement is one USDC transfer to the platform wallet. The 60/40 split is recorded in the ledgeragainst every creator the page credited, and those balances leave in batches once they clear thepayout minimum.
Funding the burner. The key ships inside the browser bundle, so treat it as public and keep itsmall. Send Celo mainnet USDC to the address printed at the top of .env.local; $0.05 covers a longsession at these prices. Do not send CELO, because the facilitator pays the transaction fee. Theper-request and per-session caps are the actual protection, and they are enforced before a signatureis created.
If a payment fails, it is almost always one of three things: the wallet is empty, the API base URLpoints somewhere that does not serve these routes, or the API does not allow the page's originthrough CORS. The page shows the raw headers and the error from each step, so you can see which.
The reference server
demo/server.ts is the same flow with an in-memory store instead of a database. Read it if you wantto see how the seller side is wired: route config, the facilitator client, settlement, replayprotection and the 60/40 split. The store is generated to production scale from a fixed seed:2,400 creators, 6,000 posts, 5,000 tracks. That makes the search, the cursors and the "50 of 2,431"summaries are exercised against a dataset the size of the real one. It is deliberately small enoughto read in one sitting.
cp .env.example .env
# set X402_API_KEY and X402_PAY_TO, leave X402_NETWORK=testnet for a first run
npm install
npm test # 135 unit tests, no network access needed
npm run demo # seller on http://127.0.0.1:3000
| Route | Price | Returns | Attribution |
|---|---|---|---|
GET /api/v1/agent/ping |
10000 |
Liveness check that proves settlement works | none |
GET /api/v1/agent/listings |
10000 each |
Public creator listings, searchable and paged | every creator on the page |
GET /api/v1/agent/posts |
10000 each |
Public posts: captions, tags, media, engagement | the owner of the audio on each post |
GET /api/v1/agent/catalog |
10000 each |
Catalog metadata with the owner of each recording | the artist who owns each recording |
GET /api/v1/agent/creator/:id |
10000 |
One public profile with their public counts | that creator |
GET /api/v1/agent/pricing |
free | The price rule, the asset and worked examples | none |
POST /api/v1/agent/chat/interpret |
free | Turns a visitor sentence into one call | none |
GET /api/v1/agent/stats |
free | How many creators, posts and tracks there are, and the busiest tags | none |
GET /.well-known/agent.json |
free | A2A agent card with prices and the ERC-8004 identity | none |
GET /.well-known/mcp.json |
free | MCP server card listing the same routes as tools | none |
GET /openapi.json |
free | OpenAPI 3.1 document for every route, with x402 pricing per operation | none |
POST /mcp |
paid per tool | MCP JSON-RPC, same prices as the HTTP routes | same as the route called |
GET /api/v1/agent/reputation |
free | On-chain ERC-8004 reputation plus settled volume | none |
GET /healthz |
free | Process health | none |
GET /demo/settlements |
free | What this process settled, with the split | none |
GET /api/v1/agent/demo/settlements |
free | The same ledger in the shape the demo site renders | none |
GET /api/v1/agent/demo/creators |
free | Per-creator balances for the demo site | none |
Every public row, one page at a time
The three data routes carry ?q=, ?limit= and ?cursor=. A request costs one cent per creatorit credits, with a one cent minimum, so the price follows the page rather than the route. A pagethat credits three creators costs 30000, a page that credits fifty costs 500000, and theceiling is 2000000 for the two hundred creators the routes will credit in one request. A pagethat matches nothing costs nothing.
These are composable queries over the public data, not a fixed set of search phrases. Listingsalso accept verified=true|false, minFollowers=<number> and sort=followers|newest.Posts accept sort=newest|views. Listings default to most followed first, posts to newest first,and the catalog to title order. The same filters, sort and limit must be sent with nextCursor.
For example, top 3 creators becomes GET /api/v1/agent/listings?limit=3&sort=followers.top 3 verified fashion creators in Nigeria with at least 1000 followers becomesGET /api/v1/agent/listings?q=fashion+nigeria&limit=3&verified=true&minFollowers=1000&sort=followers.top 3 posts in Nigeria uses GET /api/v1/agent/posts?q=nigeria&limit=3&sort=views.The catalog has no public popularity ranking, so the demo does not sell an alphabetical page as"top tracks."
That is why limit is the cost control and why the seller prices the invoice from the page it isabout to serve. The quote and the rows cannot disagree: src/x402/pagePlan.ts resolves the pageonce, the 402 is priced from it, and the 200 serves that same page.
What a row pays. The credits follow the work, not the account that published it.
| The row | Who is credited |
|---|---|
| A post with the publisher's own recording | the publisher |
| A post carrying another creator's sound | the creator who owns the sound |
| A post whose audio is a commercial recording | the artist, when that artist has a Streamlivr account |
| Audio nobody on Streamlivr owns | nobody; a page with no credited creator is free |
| A catalog recording owned by a creator | the owner |
| A catalog recording with no owner here | nobody; a page with no credited creator is free |
Every post and catalog row says which of these it is, in a label block and an attribution notethe buyer can read without knowing the rule. Using a track is not owning it, so the creators whoseposts drew on a recording are never the people that recording pays. src/x402/ownership.ts is therule, in one file, with tests, and both the production API and the reference server call it.
q matches the fields that route actually holds. Listings match username, display name, bio, country(code or name), and the hashtags on that creator's public posts, so "amapiano creators" reaches thepeople behind the tag rather than accounts with the word in a name. Posts match title, description,hashtag and the publishing creator's country. Catalog matches title, artist and ISRC.
A sentence is read the way a person means it. Filler words and the noun for the dataset are dropped,so "show me creators in Nigeria" searches one country name rather than six words that have to appearin a username, and "lagos producer" still narrows to both words. If the strict reading matchesnothing, the route tries the same words as an either-one-is-enough match before it gives up.
A page with no rows is not charged. The route answers 200 with an empty page andx-no-charge: empty-page, and the signed authorization is never submitted, so a search that missescosts the buyer nothing.
curl "http://127.0.0.1:3000/api/v1/agent/listings?q=lagos&limit=25"
{
"creators": [{ "id": "cr_01310", "username": "velvet.lagos", "countryCode": "NG" }],
"page": { "total": 275, "limit": 25, "returned": 25, "hasMore": true, "nextCursor": "eyJmb2xsb3dlckNvdW50Ijoi…" },
"query": { "q": "lagos", "limit": 25 }
}
page.totalis how many rows matched, not how many came back, so a buyer can see the size of thedataset without paying for all of it.page.nextCursoris a keyset cursor, not an offset: it carries the sort values of the last rowserved, so a page stays correct while rows are inserted underneath it and a stale cursor skipsforward instead of repeating rows.src/x402/paging.tsis the whole implementation, and it isunit tested.qis tokenised, and a token has to match a field for its reading to count. The first readingdrops the words that carry no data ("show me", "creators", "in") and requires the rest, which iswhat makes "music nate dogg" find Music & Me by Nate Dogg instead of returning everythingcontaining "music". A reading that matches nothing hands over to one that accepts any remainingword, and after that the page is empty and free.limitis clamped to 200 rather than rejected, so a client that asks for too much still gets ananswer it can use.
The dataset is what the app already shows anyone: public accounts, and posts that are published andpublic, with the creators behind them. A creator who revokes agent access is removed from all threeroutes. The profile route answers 404 and their rows disappear from listings, posts and thecatalog. Emails, phone numbers, wallet addresses, KYC state and private or followers-only content arenever selected, at any price. src/x402/access.ts is the rule, in one file, with tests.
Amounts are strings in token base units. Every supported asset uses 6 decimals, so 10000 is onecent.
Ask for a paid route without paying, and the terms come back in the payment-required header,base64 encoded as x402 v2 specifies. x402 v2 leaves the body empty, which clients and validators thatread JSON see as an invoice with nothing in it, so the server repeats the same object as the JSONbody:
curl -s http://127.0.0.1:3000/api/v1/agent/catalog | jq
# or decode the header, which carries the identical object:
curl -sD - -o /dev/null http://127.0.0.1:3000/api/v1/agent/catalog \
| awk 'tolower($1) == "payment-required:" { print $2 }' \
| base64 -d | jq
{
"x402Version": 2,
"resource": {
"url": "https://api.streamlivr.com/api/v1/agent/catalog",
"serviceName": "streamlivr-catalog",
"tags": ["x402", "celo", "music", "catalog"],
"iconUrl": "https://streamlivr.com/assets/STREAMLIVR%20ICON%20(1).png"
},
"accepts": [{
"scheme": "exact",
"network": "eip155:42220",
"maxTimeoutSeconds": 120,
"payTo": "0xYourCeloWallet",
"amount": "10000",
"asset": "0xcebA9300f2b948710d2653dD7B07f33A8B32118C",
"extra": { "name": "USDC", "version": "2", "assetTransferMethod": "eip3009" }
}]
}
resource.url is the URL that was requested, query string included, so the invoice names the samepage its amount was priced from.
The extra block is the EIP-712 domain the buyer signs over. Get name or version wrong and thefacilitator refuses the payment, which is why those values live in src/x402/config.ts instead ofbeing typed at the call site. USDC reports version 2, USDT and USAT report 1.
Set X402_ENABLED=false and the data routes answer unpaid, so the search and pagination contract canbe read against a running seller without a facilitator key. That is what npm run demo does bydefault: it generates 2,400 creators, 6,000 posts and 5,000 tracks at boot, and the bundled chatpages through them for free. The two well-known discovery documents still resolve in that mode, withan empty route list and paymentEnabled: false; /mcp and the reputation read return 404, becauseneither can do anything useful without settlement.
What one payment does
- An agent asks for a paid route with no payment and gets
402plus the invoice in thepayment-requiredheader. The amount is one cent per creator that request would credit. - It signs an EIP-3009
TransferWithAuthorizationoff-chain. No approval transaction, no gas. - It repeats the request with the
payment-signatureheader. - The seller asks the Celo facilitator to verify the signature, then to settle it.
- USDC moves from the buyer to
X402_PAY_TOinside the token contract, in one transfer. Thefacilitator never holds the funds. - The seller stores the settlement and splits 60% to the creators the page credited and 40% to theplatform, one ledger row per credited creator, then replies with the data plus a
payment-responseheader holding the transaction hash.
One on-chain transfer, many ledger rows. A page that credits fifty creators is one USDC transfer tothe platform wallet and fifty balances the payout job settles later, each of them a whole centbefore the split.
Repeat the same signed payload and the stored response comes back instead of a second charge. Theauthorization nonce is single-use, so a second settlement would fail anyway.
Where creators get paid
Creators hold a Celo wallet inside the Streamlivr app, on mainnet and Sepolia, holding USDC, USDT andUSDm. Signing goes through a Web3Auth-backed provider, so key material stays on the device.
A payout only goes to an address the creator proved they control. The API issues a nonce-boundmessage, the creator signs it, and the API recovers the signer and stores the checksummed result. Onechallenge, one use, ten minutes to expire.
Money moves in three steps, never one. A batch is prepared as PENDING, approved as a separate act,and only then broadcast. Every step needs SUPER_ADMIN and writes an audit row.
PENDING -> APPROVED -> SUBMITTED -> CONFIRMED
\-> FAILED, which releases the balance for a later run
Four gates decide who is payable, read at prepare time and again at send time:
| Gate | Source | Blocks when |
|---|---|---|
| Identity | User.kycStatus |
anything other than VERIFIED |
| Fraud suspension | User.isFraudSuspended |
true |
| Fraud score | User.fraudRiskScore |
80 or higher |
| Payout wallet | primary Celo wallet | missing |
kycStatus is not a field an operator edits. It holds the answer from the identity provider,written by a signed webhook and refreshed by a reconcile worker. The payout path reads that storedanswer rather than calling the provider inline, so a slow provider cannot hold up a treasurytransfer.
Two rules keep the treasury honest. The transaction hash is stored before the receipt is awaited, soa confirmation timeout leaves the row SUBMITTED for reconcile rather than re-sending live money.Execution re-checks the wallet, so a creator who changes address between approval and send stops thepayout instead of redirecting it.
Agent discovery and reputation
An agent that has never seen this project can go from a domain name to a paid call without a humanreading these docs.
GET /.well-known/agent.json is an A2A agent card. Skills map onto the paid routes and carry theirprice in the tags. Payment detail sits in two declared capability extensions: one for x402 (scheme,network, asset, receiving wallet, facilitator, price per route) and one for ERC-8004 pointing at theregistries, the agent id and the reputation endpoint.
GET /.well-known/mcp.json describes the MCP server: transport, protocol version, the paymentmetadata key x402/payment, and one tool per route. POST /mcp is a stateless Streamable HTTPhandler that answers initialize, tools/list, tools/call and ping.
curl -s localhost:3000/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
GET /openapi.json is an OpenAPI 3.1 document built from the same route catalog: every paid pathwith its parameters, examples and 402 response, plus an x-x402 block per operation with thenetwork, asset, receiving wallet, facilitator and the minimum and maximum price. Import it into anyAPI directory or client generator.
tools/list returns only the fields the MCP spec defines, because strict clients reject unknownkeys. Call a tool with no payment and you get the x402 challenge back instead of an error. Pay it,then repeat the call with the base64 payment payload under params._meta["x402/payment"].
The MCP handler settles nothing itself. It forwards the call to the same HTTP route over loopbackwith the caller's payment header attached, so verification, settlement, attribution and replayprotection all run through one path. There is no way to reach creator data through MCP withoutpaying.
The agent is registered on Celo mainnet as id 9852. Its on-chain metadata lists nine services:the web app, the A2A card, the MCP endpoint, the OpenAPI document, and one entry per paid route. Thelogo is written as the spec image field. Route entries keep theirpath parameters as {id} templates, since a concrete creator id would become a dead link themoment that creator left the catalog.
Reputation works the way ERC-8004 intends. The registry refuses feedback from the agent's owner andfrom any operator, so the wallet that paid is the only wallet that can rate:
npm run x402:give-feedback -- --tx 0x<settlement hash> --endpoint https://api.streamlivr.com/api/v1/agent/ping
The script builds a document holding the settlement hash, buyer, amount, asset and payee, hashes itwith keccak256, and passes it as a base64 data: URI next to that hash, so anyone can re-hash thedocument and compare. Gas is paid in USDC through fee abstraction, which means a buyer with no CELOcan still leave a rating. npm run x402:agent-status reads the whole record back from the chain.
One contract detail is easy to misread. getSummary on this deployment returns an average, not asum. Two entries of 100 read back as count 2, average 100.
On-chain proof
Every hash from our own runs is listed with its explorer link indocs/ONCHAIN-PROOF.md: nine mainnet settlements, creator payouts, theERC-8004 registration and metadata update, two reputation entries, and the Sepolia history behindthem. Volume is small and the buyer is our own canary wallet. The page says so rather than dressingit up.
Verify it
npm run typecheck
npm test
npm run demo
npm run x402:buyer-smoke # needs a funded buyer key
npm run x402:verify-attribution -- 0x<txHash>
npm run x402:agent-status
The buyer canary wraps fetch with the x402 client, pins the asset and a per-payment cap, and failsunless the response carries a payment-response header with a real transaction hash. A 200 on itsown does not pass. The one exception is a page with no rows: the seller answers x-no-charge, thecanary reports a pass with no hash, and nothing was submitted.
For a testnet pass: request a paid route and read the 402, fund a throwaway buyer with Sepolia USDCfrom https://faucet.circle.com, run the canary, then check the hash onhttps://celo-sepolia.blockscout.com and confirm GET /demo/settlements recorded the same paymentwith a 60/40 split. The buyer needs no testnet CELO, because gas is sponsored.
Assets
| Asset | Mainnet | Sepolia | Decimals | EIP-712 |
|---|---|---|---|---|
| USDC | 0xcebA9300f2b948710d2653dD7B07f33A8B32118C |
0x01C5C0122039549AD1493B8220cABEdD739BC44E |
6 | USDC / 2 |
| USDT | 0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e |
not settled | 6 | Tether USD / 1 |
| USAT | 0xD2ab3C9A02DBBAB236BfEC45D1d755DF4267F771 |
not settled | 6 | Tether America USD / 1 |
USDm and the other Mento stablecoins are not supported by this facilitator. They implement EIP-2612,and the hosted settlement engine expects EIP-3009, so a payment would return 402 and never settle.The config rejects them at startup.
src/x402/config.ts refuses to boot when the network, asset, address or decimals disagree, whichkeeps a mainnet wallet from being paired with a Sepolia token.
Celo pieces in use
| Piece | Where it shows up |
|---|---|
x402 v2 exact scheme with EIP-3009 |
Every paid route, src/x402/config.ts |
| Hosted Celo facilitator, gas sponsored | src/x402/facilitator.ts, settlement in demo/server.ts |
| Fee abstraction | Payouts, agent registration and reputation writes pay gas in the settlement asset |
| ERC-8021 attribution | Appended to every transaction this code signs, checked by npm run x402:verify-attribution |
| ERC-8004 identity | Agent id 9852 on Celo mainnet, metadata built from the route catalog |
| ERC-8004 reputation | Buyer-signed feedback, read back by npm run x402:agent-status |
Settlements are broadcast by the facilitator's relayer, so the seller cannot attach an attributionsuffix to them. Those are credited to the agent wallet registered with the programme instead.Transactions Streamlivr signs itself, such as payouts and the agent registration, carry the suffixin calldata. There is no way to add one afterwards.
Scope
This repo holds the payment modules, their tests, five operator scripts, a runnable reference serverand the demo site. The production API, the mobile app and the admin console live in Streamlivr'sprivate repositories. Three reference files reproduce the wiring that lives there:reference/admin-payout-routes.ts for prepare, approve, execute and reconcile,reference/admin-revenue-routes.ts for the reporting queries, andreference/local-kyc-override.ts for the development-only eligibility switch.
What is not here yet: a scheduled job that compares stored settlement hashes against the receivingwallet's chain history, payout signing behind a KMS or hardware signer, and a distributed lock somore than one API instance can send payouts safely. Those are the next three things we would buildbefore real volume. Today the treasury sends are serialized in process, and the code says so in acomment rather than pretending otherwise.
Security
The facilitator key is attached to server-to-server calls and never reaches a client. No private keyis read from a request body, a query string or a bundle. The buyer canary and the payout signer areserver-side only.
Amounts stay as integer strings in token base units from the moment a payment is read to the momenta payout is signed. Balances larger than Number.MAX_SAFE_INTEGER do not lose a unit, and a failedtransfer releases the reserved amount by construction rather than by a correction job.
npm audit reports five advisories, all in the test toolchain (vitest, vite, esbuild).npm audit --omit=dev is clean, so nothing that runs in production is affected. Clearing them meansa major vitest upgrade, which is queued rather than rushed in the week of a submission.
References
- x402 on Celo, agent-readable guide: https://x402.celo.org/SKILL.md
- Live facilitator config: https://x402.celo.org/api/config
- Celo docs: https://docs.celo.org
- ERC-8004 agent registries: https://eips.ethereum.org/EIPS/eip-8004 and https://8004scan.io/agents/celo/9852
- ERC-8021 attribution tags: https://github.com/celo-org/attribution-tags
- EIP-3009: https://eips.ethereum.org/EIPS/eip-3009
MIT licensed. See LICENSE.