API

The six web endpoints Moi's website and agent use, with example answers, error codes and limits.

Moi's server answers six endpoints under /api. The website, the sender agent and the scripts all use these same six. Every endpoint answers JSON. Every answer carries Content-Type: application/json; charset=utf-8, X-Content-Type-Options: nosniff and Cache-Control: no-store, except the stock list, which a cache may keep for 30 seconds. A refusal always has the form {"ok": false, "error": "<code>"} with a fixed code. No text from Binance, the chain or the store ever reaches the browser. The full list of calls the website makes is in ARCHITECTURE.md.

The interface

EndpointBodyAnswer
GET /api/stocksnone{stocks: [{address, symbol, name, decimals, uiMultiplier, priceUsd, market: {open, reasonCode, reasonMsg, nextOpenTime}, logoUrl}], asOf}
POST /api/quote{stock, usdAmount, wallet}{ok, step: "approve", approveTx} or {ok, step: "swap", stock, usdAmount, swapTx, vaultApproveTx, expectedOut, minOut, priceUsd}; transactions carry only to, data and value
GET /api/gift/{id}none{ok, giftId, state, token, symbol, name, decimals, amountRaw, shares, expiry, claimKey, sealedNote, senderCompliant}
POST /api/wrap/{id}none, then the same with a PAYMENT-SIGNATURE header402 with PAYMENT-REQUIRED, then 200 {ok, wrapped, txHash}; 202 settlement_pending means replay the same header
POST /api/claim{giftId, recipient, signature, declaration: true}{ok, txHash, reused}
POST /api/judge{judgeCode, accessToken, recipient, walletProof, issuedAt, declaration: true}{ok, giftId, txHash}

The examples below show shapes. Where a value is a placeholder it is written in words.

GET /api/stocks

The stocks a sender can gift, in the vault's own order. The token list comes only from the vault on chain. Price, market status and logo come from Binance RWA Data. They are null when RWA Data has nothing for a token.

Request: no body. Answer 200, an example from 8 October 2026:

{
  "stocks": [
    {
      "address": "0x02Fca66C1D1aFB4E2A7884261eB00F63598a7436",
      "symbol": "NVDAB",
      "name": "NVIDIA Corp",
      "decimals": 18,
      "uiMultiplier": "1000778223752807865",
      "priceUsd": "235.67000000",
      "market": { "open": true, "reasonCode": "TRADING", "reasonMsg": null, "nextOpenTime": null },
      "logoUrl": null
    }
  ],
  "asOf": 1791455126659
}

uiMultiplier is the token's own multiplier as a string, where 1000000000000000000 means one token per share. logoUrl is an https link on bnbstatic.com, or null. asOf is in milliseconds. A cached list can be up to 60 seconds older than asOf says.

POST /api/quote

Asks for the transactions that buy a stock with USDT. Moi checks and dry-runs them before it answers. When the sender's USDT allowance to the trading router is short, the answer holds only the approval. The sender sends it, waits for it to be mined, and asks again. The amount runs from 1 to 100 US dollars, as a plain number such as 5 or 12.50.

Request:

{
  "stock": "0x02Fca66C1D1aFB4E2A7884261eB00F63598a7436",
  "usdAmount": "5",
  "wallet": "the sender's wallet address"
}

Answer 200, when an approval is needed first:

{
  "ok": true,
  "step": "approve",
  "approveTx": { "to": "the USDT token address", "data": "0x...", "value": "0" }
}

Answer 200, when the swap is ready:

{
  "ok": true,
  "step": "swap",
  "stock": "0x02Fca66C1D1aFB4E2A7884261eB00F63598a7436",
  "usdAmount": "5",
  "swapTx": { "to": "the trading router", "data": "0x...", "value": "0" },
  "vaultApproveTx": { "to": "the stock token address", "data": "0x...", "value": "0" },
  "expectedOut": "the stock amount in raw token units",
  "minOut": "the least the swap may deliver, 1 percent under expectedOut",
  "priceUsd": "the reference price in US dollars"
}

Every transaction carries only to, data and value. There is no gas limit and no gas price, so the sender's wallet works out its own.

GET /api/gift/{id}

Public facts about a gift, read from the chain. The id is a plain decimal number with no sign and no leading zeros. The answer has no sender address.

Request: no body. Answer 200, an example shape:

{
  "ok": true,
  "giftId": "1",
  "state": "Open",
  "token": "the stock token address",
  "symbol": "NVDAB",
  "name": "NVIDIA Corp",
  "decimals": 18,
  "amountRaw": "the amount in raw token units",
  "shares": "the amount in shares, as a decimal string",
  "expiry": "Unix seconds",
  "claimKey": "the public address of the gift's claim key",
  "sealedNote": "0x... the encrypted note",
  "senderCompliant": true
}

state is Open, Claimed or Refunded. claimKey is the public address that the link's key must produce, not the key. sealedNote is ciphertext that only the link can open. shares is the amount times the token's own multiplier, worked out exactly with no rounding.

POST /api/wrap/{id}

Takes the 5-cent gift wrap. It uses the x402 version 2 payment flow with Binance b402. Call it once with no payment to learn what is accepted.

First call, no body. Answer 402, with the same object base64 encoded in the PAYMENT-REQUIRED header:

{
  "x402Version": 2,
  "resource": { "url": "https://the-site/api/wrap/1", "description": "Moi gift wrapping", "mimeType": "application/json" },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:56",
      "amount": "50000000000000000",
      "asset": "the payment token address",
      "payTo": "Moi's payout wallet",
      "maxTimeoutSeconds": 120,
      "extra": { "name": "the token's signing name", "version": "1", "assetTransferMethod": "eip3009" }
    }
  ]
}

The amount is 0.05 US dollars in the token's 18 decimals. The list has one entry for each token and payment method Moi accepts. Second call, the same request with the signed payment in the PAYMENT-SIGNATURE header. Answer 200, with a PAYMENT-RESPONSE header:

{ "ok": true, "wrapped": true, "txHash": "0x... the settlement transaction" }

If Binance is still settling, the answer is 202 with a Retry-After header. Send the same request with the same header again after the pause. A gift that is already wrapped answers 200 with its settlement hash and charges nothing.

{ "ok": false, "error": "settlement_pending", "retryAfterSeconds": 5 }

POST /api/claim

Asks the relayer to claim a gift to the friend's wallet. The signature is made in the browser by the link's key and is 0x followed by 130 hex characters. declaration must be the boolean true. A 200 means the claim was submitted, not that it landed. The page waits for the receipt before it says the gift is claimed.

Request:

{
  "giftId": "1",
  "recipient": "the friend's wallet address",
  "signature": "0x... the claim signature",
  "declaration": true
}

Answer 200:

{ "ok": true, "txHash": "0x... the claim transaction", "reused": false }

reused is true when a claim for that gift had already been sent. The same hash comes back and nothing is sent twice.

POST /api/judge

Hands a signed-in judge one gift from the judge pool. The Privy access token proves who the judge is. The wallet proof is a signature over four lines: the words "Moi judge gift", the wallet, the Privy user and the time, made within the last 5 minutes. The server keeps every judge key, so none ever reaches the page.

Request:

{
  "judgeCode": "the judge code from the submission",
  "accessToken": "the Privy access token",
  "recipient": "the judge's wallet address",
  "walletProof": "0x... the wallet's signature",
  "issuedAt": "2026-10-08T10:00:00Z",
  "declaration": true
}

Answer 200:

{ "ok": true, "giftId": "3", "txHash": "0x... the claim transaction" }

What the browser does itself

The server never makes, sees or stores a claim key. The browser does these steps, using functions in packages/core:

  • The sender's browser makes the claim key (newClaimKey), the key proof (signKeyProof) and the sealed note (sealNote). It builds the approve and createGift transactions (buildApproveVaultTx, buildCreateGiftTx), reads the gift number from the receipt (readGiftIdFromReceipt) and builds the link (buildLink).
  • The friend's browser reads the key from the link fragment (parseLink), checks that it matches the gift (claimKeyMatches), opens the note (openNote), signs the claim for their Privy wallet (signClaim), and shows "claimed" only after the receipt holds the GiftClaimed event.
  • A judge's browser signs judgeWalletMessage with the Privy wallet and sends it with the Privy access token and the judge code.

How the code fits together

One dispatcher, route() in packages/core/src/http.ts, serves all six endpoints, for the website and for the local server alike. The path picks one of six fixed entries and nothing else, so no part of a request can choose a path, a host or a key that Moi signs with.

Limits

LimitValue
Request body8 KB. A larger body is refused with 413 body_too_large before it is parsed.
PathAt most 128 characters.
Requests from one client30 POST requests and 120 GET requests a minute, counted separately.
Requests from everyone600 a minute.
When a limit is hit429 rate_limited with a Retry-After header. If the store that counts cannot answer, the answer is 502 store_unavailable, so a broken counter never lets traffic through.
Wrong judge codesMore than 5 a minute from one client are answered 429 rate_limited, even a right code.
Payment headerAt most 8 KB of base64.
Waiting on Binance10 seconds for the Web3 API, and 25 seconds for each b402 call. One wrap request waits up to 25 seconds for a settlement before it answers 202.
Reading the chain5 seconds per call.
Function time limit150 seconds for the API route on the host.
Relayer spendA gas price ceiling and a daily cap. The defaults are 3 gwei and 0.002 BNB a day. When the day's cap is reached the answer is 429 daily_cap.

The client address and its country come only from the hosting platform's own headers, never from a header the caller chose.

Error codes

Every refusal is a fixed code. Two kinds of answer can come before any endpoint runs.

CodeStatusMeaning
not_found404The path is not one of the six, or a gift id is not a plain decimal number.
method_not_allowed405The path exists but not for that method. An Allow header names the right one.
body_too_large413The body is over 8 KB.
bad_json400The body is empty or is not JSON, on a route that needs JSON.
rate_limited429A rate limit was hit.
store_unavailable502The shared store could not answer.
judge_gifts_closed503No judge pool is set up.
server_not_configured503The server could not start with its settings.
internal500Something broke inside the server. Nothing was sent.

Quote

CodeStatusMeaning
bad_request400The body is not exactly stock, usdAmount and wallet.
bad_stock, bad_wallet400The stock or wallet is not a usable address.
not_listed400The stock is not on the vault's list.
bad_amount400The amount is not a plain number of dollars.
amount_too_small400Under 1 US dollar.
amount_too_large400Over 100 US dollars.
restricted_place, unknown_place403The request comes from a restricted place, or the host could not tell where.
quote_refused422The quote failed one of Moi's checks.
simulation_refused422The dry run did not show the trade Moi expected.
upstream_unavailable502Binance did not answer.
chain_unavailable502The chain could not be read.

Stock list and gift status

EndpointCodeStatusMeaning
GET /api/stocksupstream_unavailable502Binance or the chain did not answer.
GET /api/gift/{id}not_found404The gift was never made, or the id is not valid.
GET /api/gift/{id}chain_unavailable502The chain could not be read.

Claim

CodeStatusMeaning
bad_request400The body is not exactly giftId, recipient, signature and declaration.
bad_gift_id, bad_recipient, bad_signature400A field is not valid, the recipient is a contract, or the signature is not the gift's key.
no_declaration403declaration is not true.
restricted_place, unknown_place403The request comes from a restricted place, or the host could not tell where.
gift_not_wrapped402The gift's 5-cent wrap has not settled.
gift_not_open409The gift was already claimed or taken back.
gift_expired409The gift has expired.
gift_expiring409Less than 60 seconds remain, too soon to claim safely.
sender_blocked409The stock's issuer is holding the gift's sender.
claims_paused409The vault is paused.
claim_refused409The vault refused the claim for another reason.
too_many_attempts409The last claims of this gift failed on chain.
busy429Another claim of the same gift is in flight.
daily_cap429The relayer has spent its gas budget for the day.
gas_price_high502Network fees are above the relayer's ceiling.
relayer_unavailable, store_unavailable, chain_unavailable502The relayer, the store or the chain could not be used safely, and nothing was sent.

Wrap

CodeStatusMeaning
bad_gift_id400The gift number is not valid.
gift_not_found404The vault has no such gift yet.
gift_not_open409The gift was claimed or taken back.
gift_expiring409The gift expires in under 10 minutes, so Moi takes no fee for it.
wrap_in_progress409Another payment for this gift is still being settled.
payment_malformed, payment_mismatch, payment_reused, payment_invalid, payment_failed402The payment could not be read, did not match what Moi asked for, was already used for another gift, was refused by Binance's payment service, or never reached the chain. The answer carries the payment request again.
settlement_pending202Binance is still settling. Replay the same request.
settlement_unexpected502The settlement could not be matched on chain, so the gift is not marked wrapped.
facilitator_unavailable, store_unavailable, chain_unavailable502A service could not answer. A late store failure answers 503 with the same retry fields.
server_misconfigured500The server's wrap settings are not usable.

Judge

CodeStatusMeaning
bad_judge_code403The judge code is wrong.
rate_limited, too_many_from_network429Too many tries, or a judge gift already went to someone on this network today. A network is one IPv4 address or one IPv6 /64.
judges_busy429Four judge gifts already went out this hour. Try again in an hour.
bad_request400The body is not the expected shape.
bad_token, token_expired401The Privy sign-in could not be read, or has expired.
bad_recipient, bad_wallet_proof400The wallet is not usable, or its signature could not be read.
proof_expired401The wallet proof is more than 5 minutes old, or the device clock is wrong.
wallet_not_verified403The signature did not come from the wallet named.
no_declaration, restricted_place, unknown_place, unknown_network403The declaration, place or network check failed.
claim_pending202The claim was sent but not confirmed yet. The judge keeps the gift and must not claim again.
already_claimed409This judge already claimed a gift.
pool_empty410Every judge gift has been handed out.
try_again, gift_expiring, claim_refused503, 409, 409The handout could not finish, the next gift was about to expire, or the vault refused. Try again.
busy, daily_cap429The relayer is busy, or it has spent its gas budget for the day.
gas_price_high, auth_unavailable, store_unavailable, chain_unavailable, relayer_unavailable502A service could not answer, and nothing was handed out.
server_misconfigured500Judge gifts are not set up correctly.

Edit this page on GitHub