Build your own app on Aeroblox
The Aeroblox API lets your application authenticate its own users against Aeroblox and read your organization's asset catalogue. Users you create through it become members of your organization: they can own and trade what they buy or win from you, and they never see your library, your team, or anyone else's data.
Base URL: https://aeroblox-api-eight.vercel.app/api/public/v1
Create an API key in Settings → Developer. The key is shown once, at creation; Aeroblox stores only a hash of it and cannot show it to you again. Send it on every request:
Authorization: Bearer ab_live_xxxxxxxxxxxxxxxxxxxx
The header X-Api-Key is accepted as an alternative where setting Authorization is awkward.
Keep the key on your server. It authenticates your whole organization. Anything shipped inside a client application — web, mobile, or desktop — can be extracted by an end user, so route API calls through a backend you control. Revoke a leaked key from the same settings page; revocation takes effect on the next request.
Some endpoints identify your app; others also identify a end user. The distinction matters as you build further:
| Field | Type | Notes |
|---|---|---|
| API key | ab_live_… | Says which application is calling, and which organization it speaks for. Required on every endpoint. |
| End-user session | X-End-User-Token | The accessToken returned by register or login, sent in the X-End-User-Token header. Says which person is acting. Required in addition to the key wherever an end user acts on their own behalf. |
Creates an Aeroblox account and enrols it in your organization. The organization comes from your API key and is never read from the request, so you cannot create end users anywhere but your own org.
| Field | Type | Notes |
|---|---|---|
| email* | string | The end user's email address. |
| password* | string | At least 8 characters. |
| firstName | string | Optional display name. |
| lastName | string | Optional display name. |
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/auth/register \
-H "Authorization: Bearer $AEROBLOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "a-real-password",
"firstName": "Ada"
}'Response — 201
{
"endUser": {
"id": "d7930066-7406-42ba-8a4f-ea4ac0aea23a",
"email": "user@example.com",
"buyerId": "cmtjcr591000hk5sji41g5u3o",
"firstName": "Ada",
"lastName": null
},
"session": {
"accessToken": "eyJhbGciOiJFUzI1NiIs...",
"refreshToken": "ufs5ktvuehv3...",
"expiresIn": 3600,
"expiresAt": 1788312118,
"tokenType": "bearer"
}
}endUser.id is the Aeroblox account, stable everywhere. endUser.buyerId is that person's ownership record inside your organization — it is what asset ownership will point at.
An email that already has an Aeroblox account returns 409 conflict. Send those users to login instead: adopting an existing account without checking its password would let any app enrol a stranger's identity.
Accounts are created already confirmed. Aeroblox sends no verification mail for your users — that relationship is yours, so verify on your side if you need to.
Returns a fresh session. This also enrols existing Aeroblox users into your organization on their first successful sign-in, so a person can carry one Aeroblox identity across every app built on this API.
| Field | Type | Notes |
|---|---|---|
| email* | string | The end user's email address. |
| password* | string | The end user's password. |
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/auth/login \
-H "Authorization: Bearer $AEROBLOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "user@example.com", "password": "a-real-password" }'The response is the same shape as register. Wrong credentials return 401 unauthorized with a deliberately vague message — the endpoint will not tell a caller whether an email is registered.
Every asset in your organization's library. Returns finished assets by default and hides archived ones, along with the interior pages of flipbooks, which belong to their edition rather than standing alone as catalogue entries.
| Field | Type | Notes |
|---|---|---|
| publisher | string | Exact publisher name, case-insensitive. |
| publisherContains | string | Substring of the publisher name, case-insensitive. Useful for discovery. |
| assetType | single | flipbook | event | Filter by kind. |
| status | string | Defaults to "ready". Pass "any" to include drafts and in-progress assets. |
| limit | number | 1–100. Defaults to 25. |
| cursor | string | The nextCursor from the previous page. |
curl "https://aeroblox-api-eight.vercel.app/api/public/v1/assets?publisher=Aeroblox&limit=25" \ -H "Authorization: Bearer $AEROBLOX_API_KEY"
Response — 200
{
"assets": [
{
"id": "cmt91slkg0001l704u83sqldg",
"title": "Bell Rock",
"description": null,
"assetType": "single",
"status": "ready",
"publisher": "Aeroblox",
"creators": ["Dogma"],
"lid": "AB-MI87BSM7",
"imageUrl": "https://.../bell-rock.png",
"ipfs": { "assetCid": null, "metadataCid": null },
"hedera": { "tokenId": null, "serial": null },
"createdAt": "2026-08-25T19:18:27.376Z"
}
],
"nextCursor": "cmt5c9i270001jr04vs7oql2y"
}Page by passing nextCursor back as cursor. A null cursor means you have reached the end.
A note on publishers. The publisher is free text captured when an asset was imported, so “Aeroblox” and “Aeroblox Inc” are two different publishers as far as the data is concerned. Use publisher when you know the exact name and publisherContains when you are matching loosely.
imageUrl is a permanent IPFS gateway URL for pinned assets, and a signed URL valid for one hour otherwise. Do not store it — re-read it when you need it.
Gives an asset to an end user with no sale involved — the asset your application just decided to hand someone. Authenticated by your API key alone, with no end-user token: your server is the authority on who receives it, and an end user must never be able to grant themselves anything.
| Field | Type | Notes |
|---|---|---|
| buyerId* | string | The recipient, as returned in endUser.buyerId by register or login. |
| mint | boolean | Mint a Hedera serial now. Defaults to false — minting is an on-chain round trip of tens of seconds, which is the wrong default inside an interactive loop. |
What happens depends on the asset
A flipbook edition issues the recipient their own copy. The master stays in your library, and the end user receives an individual copy with its own LID derived from the edition, its own watermarked cover and its own signed manifest — so a thousand end users can receive the same asset and each hold a provably distinct one. Each copy is an encode job, billed as ldpe_encoding rather than against your API allocation.
A single asset transfers outright. One asset, one owner, so it can be granted exactly once — granting it again returns 409 conflict and the current owner keeps it.
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/assets/$ASSET_ID/grant \
-H "Authorization: Bearer $AEROBLOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "buyerId": "cmtjcr591000hk5sji41g5u3o" }'Response — 201, single asset
{
"kind": "asset_transfer",
"asset": { "id": "cmt...", "title": "Collector Card", "assetType": "single", "status": "ready" }
}Response — 201, flipbook edition
{
"kind": "edition_copy",
"copy": {
"id": "cmt...",
"editionAssetId": "cmt...",
"copyNumber": 7,
"lid": "3f2a...-uuid",
"status": "issued",
"coverCid": "bafy...",
"manifestCid": "bafy...",
"hederaTokenId": null,
"hederaSerial": null,
"issuedAt": "2026-09-01T12:00:00.000Z"
}
}With mint: true, a mint that fails comes back as a mintError field alongside the copy. The copy is still validly issued — only the on-chain serial is missing.
An asset with an active marketplace listing cannot be granted: cancel the listing first, or the sale would be left pointing at something that moved.
Everything the signed-in end user owns in your organization. Requires both credentials — your API key and their X-End-User-Token — and is scoped to that end user, so it can only ever return their own holdings.
An end user can hold two different kinds of thing and an inventory screen wants both, so they arrive in one list, newest first, tagged by kind: asset for something owned outright, copy for one copy of a flipbook edition.
curl https://aeroblox-api-eight.vercel.app/api/public/v1/me/assets \ -H "Authorization: Bearer $AEROBLOX_API_KEY" \ -H "X-End-User-Token: $END_USER_ACCESS_TOKEN"
{
"items": [
{
"kind": "copy",
"id": "cmt...",
"editionAssetId": "cmt...",
"title": "Volume One",
"copyNumber": 7,
"lid": "3f2a...-uuid",
"imageUrl": "https://.../bafy...",
"hedera": { "tokenId": null, "serial": null },
"acquiredAt": "2026-09-01T12:00:00.000Z"
},
{
"kind": "asset",
"id": "cmt...",
"title": "Collector Card",
"assetType": "single",
"publisher": "Meridian Press",
"imageUrl": "https://.../collector-card.png",
"acquiredAt": "2026-08-30T09:00:00.000Z"
}
]
}A copy's imageUrl is that copy's own watermarked cover, not the edition's — two end users holding copies of the same book see different images.
Every asset currently for sale in your organization, whoever owns it. This is the feed behind an in-app marketplace: organization-listed items and end-user-listed items arrive together.
Scoped to your organization, unlike the Aeroblox portal's own marketplace, which is deliberately cross-org. Your end users see your market, not unrelated organizations'.
| Field | Type | Notes |
|---|---|---|
| publisher | string | Exact publisher name, case-insensitive. |
| publisherContains | string | Substring of the publisher name, case-insensitive. |
| sort | newest | price_asc | price_desc | Defaults to "newest". |
| limit | number | 1–100. Defaults to 25. |
| cursor | string | The nextCursor from the previous page. |
curl "https://aeroblox-api-eight.vercel.app/api/public/v1/listings?sort=price_asc" \ -H "Authorization: Bearer $AEROBLOX_API_KEY"
{
"listings": [
{
"id": "cmt...",
"amountCents": 2500,
"currency": "usd",
"listedAt": "2026-09-01T10:00:00.000Z",
"seller": { "id": "cmt...", "displayName": "Meridian Press" },
"asset": {
"id": "cmt...",
"title": "On Sale",
"assetType": "single",
"publisher": "Meridian Press",
"imageUrl": "https://.../on-sale.png",
"hedera": { "tokenId": null, "serial": null }
}
}
],
"nextCursor": null
}Puts an end user's item up for sale. Needs their X-End-User-Token: ownership is the gate, and inside one organization the developer's stock and every end user's assets share a library — so an end user can list only what they actually own.
| Field | Type | Notes |
|---|---|---|
| assetId | string | The asset to sell. Use this OR editionCopyId. |
| editionCopyId | string | Sell one copy of a flipbook edition. The edition is inferred, so assetId is optional. |
| amountCents* | number | Minimum 999 ($9.99) — below that, payment processing costs more than the sale returns. |
| displayName | string | How the seller is shown in the marketplace. Defaults to their email name. |
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/me/listings \
-H "Authorization: Bearer $AEROBLOX_API_KEY" \
-H "X-End-User-Token: $END_USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "assetId": "cmt...", "amountCents": 2499 }'End users do not need a Stripe account. Sale proceeds go to your organization, not to them — see below — so nobody is put through identity verification to sell a asset.
GET /me/listings returns that end user's own listings, and DELETE /me/listings/:id takes one down. A listing that already sold stays sold.
Returns a Stripe-hosted checkout URL. Send the end user there; nothing changes hands until Stripe confirms payment. Needs the buyer's X-End-User-Token, because the item lands in that person's inventory.
| Field | Type | Notes |
|---|---|---|
| successUrl | string | Absolute http(s) URL to return the end user to after paying. |
| cancelUrl | string | Absolute http(s) URL for an abandoned checkout. |
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/listings/$LISTING_ID/checkout \
-H "Authorization: Bearer $AEROBLOX_API_KEY" \
-H "X-End-User-Token: $BUYER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "successUrl": "https://yourapplication.example/bought",
"cancelUrl": "https://yourapplication.example/shop" }'{
"checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_...",
"saleId": "cmt...",
"amountCents": 2499
}When payment completes, ownership moves automatically and the listing is marked sold. An end user cannot buy their own listing, and an abandoned checkout expires after 30 minutes leaving the item still for sale.
Sale proceeds land in your Stripe account, not the seller's. That is deliberate: it means no end user needs a payment account to sell, and Aeroblox never holds an end user's money. What it means for you is that the seller is owed credit by you, in your own application economy.
These two endpoints are that ledger.
Sales you have not yet credited — a work queue. Pass ?credited=all for full history or ?credited=true for what you have settled.
{
"sales": [
{
"id": "cmt...",
"status": "paid",
"sellerBuyerId": "cmt...", // credit this end user
"buyerBuyerId": "cmt...",
"amountCents": 2499,
"sellerCents": 2170, // with this much
"estimated": {
"developerCents": 190, // your cut
"aerobloxCents": 37,
"stripeFeeCents": 102
},
"creditedAt": null,
"paidAt": "2026-09-03T00:50:00.000Z"
}
],
"nextCursor": null
}sellerCents is exact. The estimated block is not: Stripe's real fee depends on the card and is only known from the balance transaction after settlement, so treat those as the intended split rather than settled figures.
Call this after the credit lands in your economy, to drop the sale from the queue. Already-credited sales return their existing timestamp rather than an error, because a retry is the expected shape of this call.
If your credit write succeeds and this call fails, the sale simply reappears on your next poll — so make your own crediting idempotent, keyed on the sale id.
The marketplace fee is 12% + 30¢ — your 10% plus Aeroblox's 2%, with a fixed component that mirrors Stripe's own 2.9% + 30¢. The fixed part matters: without it a percentage alone loses money on cheap items, which is also why listings have a $9.99 floor.
| Field | Type | Notes |
|---|---|---|
| $9.99 | seller $8.50 | 85.1% · Stripe $0.58 · you $0.76 · Aeroblox $0.15 |
| $24.99 | seller $21.70 | 86.8% · Stripe $1.02 · you $1.90 · Aeroblox $0.37 |
| $49.99 | seller $43.70 | 87.4% · Stripe $1.74 · you $3.80 · Aeroblox $0.75 |
| $99.99 | seller $87.70 | 87.7% · Stripe $3.19 · you $7.59 · Aeroblox $1.51 |
The seller's share rises with price because the fixed fee does not grow. Rounding always favours the seller.
Embeds an Aeroblox provenance watermark into an image and returns the watermarked bytes. Stateless: no library asset is created, nothing is written to storage, nothing is persisted. That is what makes it usable from a script or an MCP client with no library to manage first.
Two different things come back
lid is provenance the encoder mints and owns — 32 hex with a CRC. Pass it back as lid on a later encode to keep the same provenance across a re-encode. payload is a string yousupply, embedded in the file-domain channel, and is what /decodehands back. Omit it and the image still gets an lid; it just carries nothing of yours.
| Field | Type | Notes |
|---|---|---|
| image* | string | Raw base64 of the image, with no data: prefix. |
| payload | string | Up to 256 characters to embed and recover later via /decode. |
| context | string | "digital" (default) for files that stay files. "analog" for anything that will be photographed or filmed off a screen or print — a louder, more visible embed that survives a camera lens. |
| lid | string | Reuse an existing provenance lid. Only ever a value a previous reply returned — a hand-made value is denied rather than silently replaced. |
| measurePsnr | boolean | Ask the encoder to measure watermark-only PSNR in dB. Costs an extra pass over the image, so it is off by default. |
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/encode \
-H "Authorization: Bearer $AEROBLOX_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"image\": \"$(base64 -i cover.png)\", \"payload\": \"catalogue-01\" }"{
"lid": "9f3a1c...",
"payload": "catalogue-01",
"channel": "ming",
"image": "iVBORw0KGgo...",
"metrics": { "psnr": null, "delta": null, "tier": 3 }
}The watermark is imperceptible — the returned image looks identical to the one you sent. q1, the cam-oriented channel, is deliberately not offered here: it is reserved for tickets a gate camera must read, and spending it would mean a louder, more visible embed for callers who are not photographing anything.
Images ride inline as base64, so the JSON body is capped at 4 MB — roughly a 3 MB image. Anything larger belongs in the portal's library instead.
Reads a watermark back out of an image and returns what was embedded. Two independent questions are answered, and the response keeps them separate.
Verification asks whether a genuine Aeroblox watermark was recovered from the pixels. It fails closed: a code is never read from your request, so you cannot assert one the image did not carry. A failure is a 422.
Recognition asks whether that identifier matches an asset in the Aeroblox library. A verified image with no matching asset returns asset: null with a 200 — a successful decode, not a failure. Everything encoded through /encode lands in exactly this state, because that endpoint creates no asset.
| Field | Type | Notes |
|---|---|---|
| image* | string | Raw base64 of the image, with no data: prefix. |
{
"verified": true,
"payload": "catalogue-01",
"channels": { "q1": null, "ming": "catalogue-01", "b1": null },
"asset": null,
"stats": { "decodeErr": 0, "pilotCalBer": 0, "pilotSnr": 0 }
}There is no lid in a decode response, and that is deliberate. /encode returns an encoder-minted provenance lid; the only thing recoverable from the pixels is the payload above. Calling both “lid” would invite mistaking one for the other.
What this does not prove
The watermark is unsigned, so a recovered code attests that this image carries this code — not that the image is genuine. Anyone who can read a code off a real asset can embed it in a forgery and be recognized here. Closing that needs a signature over the asset identity and content digest, verified at decode time, and is tracked separately.
A decode also fails when the mark has been destroyed rather than never applied — re-compression, cropping and resizing all degrade it. A 422 means “no watermark recoverable”, not “never encoded”.
Encode and decode are also available as an MCP server, so an AI client — Claude Code, Claude Desktop, or anything else speaking the protocol — can watermark and verify images directly. It wraps the two endpoints above; there is nothing it can do that the API cannot.
| Field | Type | Notes |
|---|---|---|
| aeroblox_encode | tool | Embeds a watermark into an image, writes the watermarked file to disk, and returns the provenance lid. Takes source, payload, outputPath, context, measurePsnr. |
| aeroblox_decode | tool | Reads a watermark back out and returns the embedded payload and whether it matches a library asset. Takes source. |
Install
Nothing to install. The server is published to npm as aeroblox-mcp (MIT), so your MCP client fetches it on first use — point it at npx and you are done.
Configuration
The server reads your API key from its own process environment, never from a tool argument — tool arguments are generated by a model and end up in the conversation transcript. In Claude Code (~/.claude.json) or Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"aeroblox": {
"command": "npx",
"args": ["-y", "aeroblox-mcp"],
"env": {
"AEROBLOX_API_KEY": "ab_live_...",
"AEROBLOX_API_BASE_URL": "https://aeroblox-api-eight.vercel.app"
}
}
}
}Requires Node 18 or newer. To run a local checkout instead — the source is at github.com/aerobloxinc/aeroblox-mcp — use "command": "node" with the absolute path to dist/index.js.
Then ask for it in plain language:
Encode ~/Pictures/cover.png with Aeroblox and embed the payload catalogue-01 Decode ~/Pictures/cover-aeroblox.png and tell me what is in it
Getting an image to the tool
This is the one real constraint, and it is worth knowing before you try. MCP tool arguments are JSON produced by the model, and there is no channel through which a host hands an image the user dropped into chat to a tool as bytes. A model cannot transcribe a megabyte of base64 from an image it was only shown.
So source must be something the model can pass faithfully: a file path on the machine running the server (the normal case), an https:// URL, or raw base64 if you were given it as text. If an image is pasted into the conversation, save it to a file first and pass the path — the tool descriptions tell the model to ask for exactly that.
Why encode returns a path, not the image
MCP allows a tool to return an image, and handing the watermarked file straight back into the conversation looks like the obvious design. It is a poor one: base64 of a 3 MB image is about 4 MB of text — an enormous number of tokens — and the watermark is imperceptible, so there is nothing for the model to usefully look at. The deliverable is a file you keep, so the tool writes it and reports the path, defaulting to the source filename with an -aeroblox suffix beside the original.
A giveaway is a capped allocation of free copies that end users claim for themselves. It is the third way an asset changes hands, and the difference from the other two is worth being precise about: a listing is priced and sells one thing; a grant is free but your server decides who gets it; a giveaway is free and self-service.
The shape this is built for: you publish a book as an edition, sell copies of it, and also open 100 free ones. Each claimant ends up with their own distinct copy — its own LID derived from the edition's Merkle root, its own watermarked cover, its own signed manifest, and its own Hedera serial. Provenance is preserved because the copy is derived from the edition rather than detached from it.
What a claim costs you
Free to the claimant, not to you. Every claim runs a real encode and mint against your organization's LDPE encoding allowance — the same allowance issuing a copy by hand draws on. One hundred free copies is one hundred encodes.
Free copies currently on offer in your organization. Exhausted and cancelled ones are hidden unless you pass includeClosed=true.
Send an end-user token alongside your key and each row gains claimed_by_you. Without one it is null — unknown, not false, which matters: showing a claim button to someone who already holds a copy is worse than showing nothing.
{
"giveaways": [
{
"id": "cmt...",
"status": "active",
"quantity": 100,
"claimed": 37,
"remaining": 63,
"mints": true,
"claimed_by_you": false,
"asset": { "id": "cmt...", "title": "Volume One", "assetType": "flipbook", "imageUrl": "https://..." }
}
],
"nextCursor": null
}claimed counts reserved slots, which includes copies still being produced — so claimed can exceed the number of finished copies for a minute or two.
Takes one copy for the end user named by X-End-User-Token. Needs both credentials — the key says which application, the token says which person — because “one per person” is only meaningful if we know who is asking.
Answers 202, not 200
The slot is taken immediately and durably; the copy is produced by a background job. That is the contract, not an implementation detail leaking out: producing a copy means an encode, two IPFS pins, a manifest signature and a mint. Blocking the claimant on all of that would risk handing them a timeout and losing them their place.
curl -X POST https://aeroblox-api-eight.vercel.app/api/public/v1/giveaways/$GIVEAWAY_ID/claim \ -H "Authorization: Bearer $AEROBLOX_API_KEY" \ -H "X-End-User-Token: $END_USER_ACCESS_TOKEN"
{
"claimId": "cmt...",
"status": "pending",
"queued": true,
"message": "Your free copy is reserved and is being produced."
}Two different 409s, and a client should branch on error.reason rather than the message: exhausted means every copy has gone, and already_claimed means this person already has one. One per person is enforced by a database constraint, not an application check.
The other half of that 202. Scoped to the claimant — a claim belonging to someone else is indistinguishable from one that does not exist.
| Field | Type | Notes |
|---|---|---|
| pending | status | Slot held, production not started. Keep waiting. |
| issuing | status | Production underway. Keep waiting. |
| issued | status | Done — the copy is in their inventory with its own LID and, if the publisher asked for it, its own Hedera serial. |
| failed | status | Permanently not happening, and the slot has gone back to the pool. `reason` says why; retrying the same claim will not help. |
{
"claimId": "cmt...",
"status": "issued",
"copy": {
"copyNumber": 38,
"lid": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"coverUrl": "https://.../bafy...",
"hedera": { "tokenId": "0.0.12345", "serial": 38, "mintedAt": "2026-10-09T..." },
"minted": true
},
"asset": null
}A copy that is issued with no serial is still genuinely owned — it has its LID, cover and signed manifest, and can be minted later. That is reported as a successful claim rather than a failure, because it is one.
Single assets
A one-off asset can be given away too, but only once — ownership moves to whoever claims first, so quantity must be 1 and the response carries asset instead of copy. To hand out many free copies you need an edition, where each claim issues a genuinely distinct one.
Publishers open and manage giveaways from the asset page in the portal — pause, resume, raise the quantity, or cancel. Pausing and cancelling stop new claims; copies already claimed stay with their owners.
Every failure has the same shape, and clients should branch on the code, not the text:
{ "error": { "code": "invalid_key", "message": "API key is not valid." } }| Field | Type | Notes |
|---|---|---|
| unauthorized | 401 | No key presented, or bad end user credentials. |
| invalid_key | 401 | The key is not recognised. |
| key_revoked | 401 | The key was revoked in Settings. |
| invalid_request | 400 | A parameter is missing or malformed. |
| conflict | 409 | Already exists — most often an email that is already registered. |
| rate_limited | 429 | Too many calls this minute. Back off and retry. |
| allocation_exhausted | 429 | The plan's included API calls are used up. |
| internal | 500 | Our fault. Safe to retry. |
Calls are metered as API Access against your plan, and appear per endpoint on the Billing tab. Only successful calls are billed — a 400 or 401 while you are debugging costs nothing, though it still counts toward the per-minute rate limit.
| Field | Type | Notes |
|---|---|---|
| Free | 500 / cycle | 10 requests per minute. |
| Basic | 2,500 / cycle | 30 requests per minute. |
| Advanced | 25,000 / cycle | 60 requests per minute. |
| Pro | 100,000 / cycle | 120 requests per minute, no per-call overage. |
| Enterprise | Negotiated | 600 requests per minute. |
Exceeding the rate limit returns 429 rate_limited with a retry hint in the message. Exhausting the plan allocation returns 429 allocation_exhausted on free plans; paid plans accrue overage instead and keep serving.
The loop is complete: authenticate end users, read your catalogue, grant assets, read an inventory, browse the market, sell, buy, and settle.
The next step is cash-out — letting an end user who wants real money receive it directly instead of in-application credit. The groundwork is already here: the Seller record created for each end user carries an empty Stripe account field, and filling it in through Stripe Connect Express onboarding is the whole of the change. Sales would then route that end user's share straight to them, while everyone else keeps the simpler credit path.