Building a mini app

A mini app isjust a website. Its contents can stay on your own server. Vespo only lends youa place to open it and proof of identity.

The big picture

  1. ① Entry … use the ticket once to learn who opened it
  2. ② Screen … load the SDK and receive the current state
  3. ③ Server … verify scores, saves and presence here

⚠️ The most important thing is not to mix ② and ③. ② is fast butthe user can tamper with it; ③ is slower butcannot lie. Build the visuals on ② and the decisions on ③.

From registration to publishing

  • ① Register (developer site → Mini apps)

    Enter a name, URL, one-line description, the information you want (scopes) and an icon, and an API key is issued on the spot (shown once, so copy it). The URL is the entrance that receives the ticket: when Vespo opens it, ?rv_token=… is appended. https only.

  • ② Try it while it is a draft

    From the moment you register, only you and your testers can open it. In "Test" on the developer site you can also run any URL without registering (localhost too). It shows whether the ticket was actually used (whether verify was called).

  • ③ Add testers

    Name the people you want to try it with on the developer site. The draft appears in their list in the Vespo app.

  • ④ Apply to publish

    We open it and actually try it, then email you the result. What we check is "the content at the time of submission". A mini app is just a website, so replacing the content after publishing is technically possible = in return, players can report it from the "⚠" on the panel.

  • ⑤ What you can and cannot change while published

    Name, description and icon can be changed while published. Only the URL cannot (so that something different from what was reviewed does not run). To change the URL: "unlist" → fix → apply again. Published mini apps cannot be deleted.

Places and room keys

  • You decide the places. Vespo does not impose a map.

    The place for saves is a free-form string. One world for everyone → place="world"; zones → "zone_3"; a private room per person → "house_" + user.key. The chat's room key is just one convenient default among these.

  • A room key represents "the members", not "a map".

    Only people in the same chat get the same key. A mini app that is fine with a place anyone can enter does not need it. It matters when you want a closed group: "just the four people in this chat".

  • The key cannot be forged, so you do not have to build an entry system.

    The key is handed out by Vespo's server, and the room cannot be traced from its contents. So "the password leaked and a stranger got in" cannot happen. You can use the relationships that already exist instead of building invites, admission checks and kicking yourself.

  • ⚠️ The key only arrives when opened from a chat.

    When opened from the list in Vespo's settings there is no room, so room is absent. The same person opening the same mini app gets different things depending on where they opened it. Either make it work solo when there is no key, or tell them to open it from a chat (saves carry over either way).

① Use the ticket once at the entrance (server)

When Vespo opens your page, the URL carries ?rv_token=…. ⚠️ The ticket is single-use and lasts 60 seconds. Use it on the screen and every reload will fail, so verify it at the entrance and replace it with your own cookie. ⚠️ stateToken (the server token used in ④) is only returned if you chose "call status (call)" when registering.

POST https://www.vespo.app/v1/mini/verify
Authorization: Bearer <your API key>
Content-Type: application/json

{ "token": "the ticket from the URL" }

→ {
     "user": { "key": "…", "name": "…", "avatarUrl": "…" },
     "room": { "key": "…" },          // only with scope: room
     "payload": "…",                  // the payload when opened from an invite card
     "stateToken": "…"                // only with scope: call (used below)
   }

⚠️ user.key is an ID unique to your mini app. The same person has a different value in other mini apps.
⚠️ The name and icon belong to "the box that person is using right now".
   They can differ depending on where the same person opened it from (the ID never changes).

② On the screen, load the SDK — one line

It hides the fact that there are three ways to connect depending on the device. ⚠️ The SDK is optional; you can also use the raw messages below.

<script src="https://www.vespo.app/sdk/v1.js"></script>
<script>
  Vespo.connect().then(function (rv) {

    // ── Receive ────────────────────────────────
    rv.on('state', function (s) {
      // s.inCall / s.with / s.speaking          (⚠️ these reach the screen, so do not trust them)
      // s.levels … voice level 0–1. ⚠️ silent people are absent = treat missing as 0
    });
    rv.on('visibility', function (d) { /* if d.active is false, pause sound and timers */ });
    rv.on('closing', function () { /* clean up. ⚠️ do not rely on this for saving */ });

    // ── Show something to people (⚠️ call these the moment a button is pressed; desktop opens a popup) ──
    document.querySelector('#call').onclick = function () {
      rv.invite({ payload: 'dungeon:42' });      // the user chooses who to send it to
      rv.post({ text: 'Reached level 10' });     // the text is a draft; the user can edit it
      rv.gift({ code: 'gem' });                  // give. The user chooses the recipient too
    };

    // ── Sell ───────────────────────────────────
    // ⚠️⚠️ **Do two things on the developer board before selling.**
    //    ① Register what you sell (/developers → your mini app → "Products")
    //    ② **Connect Stripe and get to "ready to receive"** (/developers/settings)
    //    Money goes from the buyer **straight to your account**,
    //    so without a receiving account the payment itself cannot be created.
    //    If either is missing, rv.buy() fails with seller_not_ready.
    document.querySelector('#buy').onclick = function () {
      rv.buy({ code: 'gem' }).then(function (r) {
        // ⚠️ The reply has two shapes depending on the device. Handle both or one of them silently does nothing
        if (r.opened) return;                    // phone = the host already opened it
        window.open(r.url, '_blank');            // desktop = you open it
      }).catch(function (e) {
        // ⚠️ seller_not_ready = **your side is not ready yet** (not the buyer's fault).
        //    Show "not ready yet" and finish ① and ② above.
        if (e.code === 'seller_not_ready') { alert('Not ready yet'); return; }
        if (e.code === 'not_connected')   { alert('Please open this from Vespo'); return; }
        alert('Could not buy');
      });
      // ⚠️ You cannot tell here whether they bought it. Your server asks
      //    /v1/mini/purchases, and only then do you hand it over.
    };

    // ── The host tells you what is available ─────
    if (rv.can('voice')) {
      rv.voice({
        volumes: { someKey: 0.3 },
        pan: rv.can('voicePan') ? { someKey: -0.9 } : undefined,
      });
    }
    if (rv.can('haptic')) rv.haptic('hit');      // vibration. ⚠️ phone panel only
  });
</script>

Types (TypeScript): https://www.vespo.app/sdk/v1.d.ts
⚠️ rv.anything() passes the name straight through. When the host gains features, you do not need to update the SDK.

③ Without the SDK (raw messages)

This is all it does. ⚠️ There are three ways to connect, and the SDK only papers over that difference.

// ⚠️ Signal names are vespo:… (robverse:… until 2026-09-04. Old hosts may still send the old names)
var port = null;   // ⚠️ Declare it, or the first toVespo() throws a ReferenceError

// Receive (same code on every device)
window.addEventListener('message', function (e) {
  // ⚠️ On desktop the first message carries a "return path" port.
  //    Use the latest one you receive, and reply as soon as you get it
  if (e.ports && e.ports[0]) {
    port = e.ports[0];
    port.postMessage({ type: 'vespo:ready' });
  }
  var d = e.data;
  if (d && d.type === 'vespo:state') { /* current state */ }
});

// Send (one of three ways)
function toVespo(msg) {
  if (port) return port.postMessage(msg);                                  // desktop (separate tab)
  if (window.ReactNativeWebView)                                           // phone (panel)
    return window.ReactNativeWebView.postMessage(JSON.stringify(msg));
  if (window.parent !== window) return window.parent.postMessage(msg, '*'); // inside a frame
}

toVespo({ type: 'vespo:ready' });                    // ask for the latest state again
toVespo({ type: 'vespo:invite', rid: 'r1', payload: 'dungeon:42' });
// → { type: 'vespo:invite_result', rid: 'r1', ok: true, closed: true }

// Announce a result ("Reached level 10" and so on)
toVespo({ type: 'vespo:post', rid: 'r2', text: 'Reached level 10' });
// → { type: 'vespo:post_result', rid: 'r2', ok: true, closed: true }

toVespo({ type: 'vespo:buy', rid: 'r3', code: 'gem' });
// → { type: 'vespo:buy_result', rid: 'r3', ok: true, opened: true }   phone
// → { type: 'vespo:buy_result', rid: 'r3', ok: true, url: '…' }       desktop

toVespo({ type: 'vespo:gift', rid: 'r4', code: 'gem' });
// → { type: 'vespo:gift_result', rid: 'r4', ok: true, closed: true }
// ⚠️ On desktop the sheet appears in the host's window (not visible from the separate tab).
//    The SDK's rv.gift() opens a popup instead, so use that.
// ⚠️ Whether it was actually sent is not returned (so nobody can build "won't close until you send")

④ Decide scores from your own server

⚠️ Values that reach the screen can be tampered with by the user. Growth, scores and presence must be checked here. All of these need both the API key and the stateToken. ⚠️ stateToken is only returned by ① if you chose "call status (call)" when registering (without it, nothing in ④ works). It lasts 30 days. When it expires, start over from ① = have the user open the app again.

POST https://www.vespo.app/v1/mini/state   { "stateToken": "…" }
  → { inCall, since, roomKey, with:[…], othersCount, now }
     ⚠️ This comes from LiveKit's records, so it cannot lie.
     ⚠️ now is **the server's clock**. If anything depends on time (once a day, a time limit),
        use this rather than the screen's clock. Device clocks can be changed.

POST https://www.vespo.app/v1/mini/save    { "stateToken": "…", "place": "…", "data": {…}, "ifVersion": 3 }
  → { ok: true, version: 4 }   /  409 if the version does not match (re-read and retry)
     ⚠️ place is "a string you choose freely".
        4 people playing together → room.key / one shared world → "world" / private rooms → "house_" + user.key
     ⚠️ Omit data to read. Up to 1 MB per call.

POST https://www.vespo.app/v1/mini/place   { "stateToken": "…", "place": "…" }
  → { here: [{ key, name, avatarUrl }] }
     ⚠️ "Say I am here" and "ask who is here" in one call. Call it every 20–30 seconds
        (after 60 seconds of silence you are counted as gone).

POST https://www.vespo.app/v1/mini/score   { "stateToken": "…", "board": "score", "value": 1200 }
  → { value: 1200, order: "high", keep: "best", changed: true }
     ⚠️ board is "a string you choose freely". For weekly boards put the week in the name
        ("2026w35"). We do not reset anything automatically.
     ⚠️⚠️ order (high = bigger is better / low = smaller is better) and keep (best = personal best /
        last = always overwrite) are **fixed by the first call to that board and never change**.
        The order/keep in the reply are what is actually in effect.
     ⚠️ changed:false means "did not beat the personal best", not failure.

POST https://www.vespo.app/v1/mini/purchases { "stateToken": "…" }
  → { purchases: [{ id, code, name, amountJpy, at, gift }] }
     ⚠️ Only confirmed payments. Merely opening the checkout page does not appear.
     ⚠️⚠️ Never accept "bought it" from the screen. Words from the screen can be faked.
     ⚠️ For consumables (tickets, coins) record the id in your own table
        so you never hand the same purchase over twice.
     ⭐ gift:true means "given by someone". The owner is the recipient.

POST https://www.vespo.app/v1/mini/rank    { "stateToken": "…", "board": "score", "scope": "friends" }
  → { me: { rank, value }, total, rows: [{ key, name, avatarUrl, rank, value, me }] }
     ⭐ scope:"friends" = Vespo friends + people in the chat it was opened from.
        **Who is friends with whom is never passed to you** (we filter, then return only ranks).
        When not opened from a chat, it is friends only.
     ⚠️ scope:"all" is everyone who has put a score on that board.
     ⚠️ Your own row is always included even if out of range (so you can show "you are #128").
     ⚠️ Names and icons are included only when name / avatar were consented to.


POST https://www.vespo.app/v1/mini/friends  { "stateToken": "…" }
  → { friends: [{ key, name, avatarUrl, lastOpenedAt }] }
     ⭐ Friends **who have opened this mini app**.
        rank only returns people who posted a score, so use this to invite
        "friends who have it but have not played yet".
     ⚠️ Friends = Vespo friends + people in the chat it was opened from (same as rank).
        **Who is friends with whom is never passed** (only people who opened this mini app are returned).


POST https://www.vespo.app/v1/mini/notify  { "to": ["user key", …], "text": "text", "payload": "where to" }
  → { sent, capped, unknown }
     ⭐ A notification you can send without the user doing anything ("your tree is wilting" and so on).
     ⚠️ That is why there are three brakes = **only people who have opened it** / **only people who have it on**
        (they turn it off in the Vespo app) / **3 per person per day** (extras are counted in capped and dropped).
     ⚠️ text up to 60 characters, up to 50 people per call. The title always carries your mini app's name.
     ⚠️ This one alone works with **just the API key**, no stateToken (as long as you know the recipient's key).

All the signals

This is everything the screen and the host say to each other. If you use the SDK you do not need to memorise it.

DirectionSignalWhat it carries
Host → mini appvespo:stateThe current state. Arrives when a call starts or ends. It also carries rv.can (the list of what is available).
Host → mini appvespo:visibility{ active } false when the app goes to the background. Pause sound and animation (the panel keeps running even when folded).
Host → mini appvespo:closing{ graceMs } About to close. Clean up (save, etc.) and return closing_result. Returning it closes immediately. Without it, it closes anyway after graceMs (currently 500 ms).
Mini app → hostvespo:readyAsk for the latest state again. Send once right after the screen is ready.
Mini app → hostvespo:invite{ payload } Show the invite sheet. The user chooses who to send to. → invite_result { closed }. ⚠️ Whether it was sent is not returned (so nobody can build "won't close until you send").
Mini app → hostvespo:post{ text } Show the sheet that posts a result to the chat ("Reached level 10" and so on). ⚠️ text is a **draft**: the user can edit it or close without sending. The user also chooses where it goes. → post_result { closed }. ⚠️ Whether it was sent is not returned.
Mini app → hostvespo:voice{ volumes, pan } Voice level and left/right. → voice_result { ok, panApplied }
Mini app → hostvespo:buy{ code } Buy. → buy_result has two shapes by device = phone { opened:true } (the host opened it) / desktop { url } (you open it with window.open). ⚠️ Whether they bought is not returned (check /v1/mini/purchases).
Mini app → hostvespo:gift{ code } Show the gift sheet. The user chooses the recipient. → gift_result { closed }. ⚠️ Call it through the SDK's rv.gift() (with call() the desktop popup does not open).
Mini app → hostvespo:haptic / vespo:stopHaptic{ name | pattern } Vibrate / stop. → haptic_result { played, why }. ⚠️ Only the phone panel can vibrate (it is not an error when it cannot).
Mini app → hostvespo:closing_resultReturn this once you have cleaned up after closing. It closes immediately (and closes anyway after graceMs).
Rulesvespo: and robverse:Signal names changed from robverse:… to vespo:… on 2026-09-04. The host accepts both. Old hosts may still send the old names, so if you read raw messages it is safer to accept both (the SDK does).
Rulesrid and *_resultAttach a rid when sending and <name>_result comes back with the same rid. Unsupported names return ok:false / error:'unsupported' plus the list of what is available.

Selling

The code is in ② (rv.buy()). This is the money side you should know first.

  • Two things before selling: register your products, and connect Stripe.

    Register products at developer site → your mini app → "Products" (a code and a price; the allowed price range is shown there). Set up receiving at developer site → Settings → "Connect Stripe". Identity verification and the bank account are entered on Stripe's screen (Vespo does not hold them). If either is missing, rv.buy() fails with seller_not_ready = it is not the buyer's fault, so do not show wording that suggests it is.

  • Your share is 85% of what remains after payment fees.

    Money goes from the buyer straight to your Stripe account, and at that moment the remainder after payment fees is split 85% you / 15% Vespo. Payment fees vary by card, so what you receive shifts by a few yen (the amount shown at registration is a guide).

  • Payouts start at ¥3,000.

    Paying out tiny amounts every time would lose money to transfer fees. Until then, your balance is visible in the developer site's settings.

  • Confirm "bought it" on the server.

    The screen is never told whether the purchase happened (the words could be faked). Your server asks /v1/mini/purchases and only then hands it over. For consumables (tickets, coins) record the id so you never hand it over twice.

  • Gifts work too.

    With rv.gift({ code }) the user chooses "which chat, which person", and once paid a card appears in the recipient's chat. The owner is the recipient (it appears in purchases with gift:true). The recipient does not need to be playing your mini app.

Failures and limits

  • 401 unauthorized / 404 not_found (verify)

    The key is missing or wrong / the ticket is missing, expired, already used, or belongs to another mini app. ⚠️ These three are not distinguished (so brute-forcing gets no hints). Tickets are single-use and last 60 seconds.

  • 409 (save)

    ifVersion did not match = someone wrote first. Re-read, then retry.

  • e.code thrown by the SDK

    not_connected (opened outside the host) / no_scope (called something you did not choose at registration) / unsupported (the host is old) / timeout (no reply) / preview_only (cannot run inside "Test") / seller_not_ready (not set up to sell yet).

  • Limits

    Ticket 60 seconds, single-use / stateToken 30 days / save 1 MB per call / place counts you as gone after 60 seconds of silence / notify 60 characters, 50 people per call, 3 per person per day / the post draft can be edited by the user.

Things to remember

  • You can show it in the person's own language.

    The reply from /v1/mini/verify at your entry page carries locale (`ja`, `en`, `zh-Hant`, …). If your app is client-only, rv.meta.locale from the handshake carries the same thing. ⚠️ It can differ from the device language (navigator.language) — what the person picked in Vespo is the right one, so prefer it. ⚠️ Fall back to your own default if you get a language you do not support. ⚠️ Older versions of the app do not send it (null).

  • Do not rely on saving at shutdown.

    We send vespo:closing before closing, but there is only time for about one save. If the app is killed, the browser is closed, or the connection drops, no signal arrives at all. Save "whenever something changes"; treat the shutdown save as a bonus.

  • Do not trust what reaches the screen.

    Use it for visuals all you like, but when deciding scores always call /v1/mini/state from your own server.

  • Never put the API key in the browser.

    Calling /v1/* from the page's JavaScript exposes the key to everyone. Call only from your server.

  • The ticket is single-use and lasts 60 seconds.

    Use it at the entrance and run on your own cookie afterwards. Use it on the screen and every reload will fail.

  • Set cookies with SameSite=None; Secure; Partitioned.

    There are two ways your page gets opened, and cookies behave differently. The phone app opens your page as the top-level document, so ordinary cookies work; but the web version added to the home screen (PWA) opens it inside a frame, which makes your page "third-party", and SameSite=Lax cookies are not sent (the identity cannot be read and it looks like being logged out right after opening). None + Secure + Partitioned works in both (Partitioned = a separate cookie per embedding site; Chrome/Edge 114+, Safari 18.4+. Use the same flags when deleting it too). On older browsers, players can move it to a separate tab with the "↗" above the frame.

  • Make sure you can be embedded.

    If X-Frame-Options or CSP frame-ancestors refuses embedding, the contents show up blank in the home-screen web version (we cannot tell that it was refused). If you do restrict it, add https://robverse-app.expo.app to frame-ancestors.

  • Do not call invite / gift through rv.call().

    The SDK passes names straight through, so rv.call('gift', …) goes through too, but opening the popup is done inside the SDK and gets skipped. On desktop nothing appears. For these two, call them by name: rv.invite() / rv.gift().

  • "Bought it" never reaches the screen.

    If the screen were told "bought it", faking those words would get the item for free. Your server asks /v1/mini/purchases and only then hands it over. Prices are also authoritative in Vespo's database, so you cannot pass an amount from your side (only the code).

  • Prevent write collisions with ifVersion.

    If two people write the same place at once, the one without ifVersion is silently lost. On 409, re-read and retry.

A working example

Our Tree — a mini app where a tree grows with the time you spend on calls. It uses ①–③ end to end (identity, place, state, saves, invites, voice). Openthe sample app from the Vespo app to try it.

⚠️ The growth time is decided by the sample's server asking /v1/mini/state. Faking "in a call" on the screen does not grow the tree. That is what ③ looks like.

Register a mini app

Even without registering, you can run it from"Test" by entering a URL (localhost works too).

SDK: https://www.vespo.app/sdk/v1.js / Types: /sdk/v1.d.ts