i99dash docs
Recipes

Fetch and render a list

The simplest real recipe — call your backend once, render a list, handle the failure path. ~10 minutes.

This is the recipe most developers want first: call a backend, get JSON back, render it. We'll build a tiny "fuel stations near me" list. ~10 minutes if you've done the Quickstart.

The finished widget:

┌──────────────────────────────────────┐
│  Fuel near you                       │
│  ────────────────────────────────    │
│  Aramco — Olaya Rd       2.33 SAR    │
│  Shell  — King Fahd Rd   2.34 SAR    │
│  ADNOC  — Tahlia St      2.35 SAR    │
│                                      │
│  3 stations · updated just now       │
└──────────────────────────────────────┘

What you'll exercise:

  • MiniAppClient.fromWindow() — the client.
  • client.callApi() — backend proxy.
  • The success / error envelope branching.
  • Loading + empty + error states.
  • A fixture file so the dev-server has something to return.

Prerequisites

  • Node 20 +, pnpm 9 +.
  • ~10 minutes.

Before you start: where does the backend live?

The most-asked question on this recipe. In the call

client.callApi({ path: '/api/v1/fuel-stations', method: 'GET' });

/api/v1/fuel-stations is not a URL — it's a route key the host maps to one of two things, depending on environment:

Where you runWhat backs /api/v1/fuel-stations
Local dev (pnpm dev)A JSON fixture in mocks/*.json. No network call.
ProductionYour own HTTPS service, mapped path-by-path in the host's allow-list. Configured by i99dash ops at deploy time.

There is no shared platform backend for mini-apps to call for free — the i99dash platform's own API is internal infrastructure, not a developer surface. Every production callApi path resolves to a service you own and run. (For dev, the mocks/ fixture is the backend, so you can finish this recipe without standing anything up.)

The wiring for your real backend is at the bottom of this page; it's a one-time coordination step with ops.

1 — Scaffold

pnpm dlx @i99dash/sdk-cli init fuel-list --template vanilla
cd fuel-list
pnpm install

The vanilla template gives you src/index.html, src/main.ts, manifest.json, and an example fixture in mocks/.

2 — Replace src/main.ts

import { MiniAppClient, type MiniAppContext } from '@i99dash/sdk';

type Station = { name: string; road: string; price_sar: number };

const root = document.querySelector<HTMLElement>('#root')!;
const client = MiniAppClient.fromWindow();

void render();

async function render() {
  // 1 — show a loading state up front so the user sees something.
  root.innerHTML = '<p class="loading">Loading stations…</p>';

  // 2 — pull context once. Used for the "near you" wording + the
  // direction flip on Arabic locale.
  let ctx: MiniAppContext;
  try {
    ctx = await client.getContext();
  } catch (e) {
    return renderError(`Couldn't read your context. ${describe(e)}`);
  }
  document.documentElement.lang = ctx.locale;
  document.documentElement.dir = ctx.locale === 'ar' ? 'rtl' : 'ltr';

  // 3 — fetch the stations. Note the envelope branch.
  const r = await client.callApi<{ stations: Station[] }>({
    path: '/api/v1/fuel-stations',
    method: 'GET',
    query: { vin: ctx.activeCarId },
  });

  if (!r.success) {
    return renderError(
      r.error.code === 'disallowed_path'
        ? `Backend not on the allow-list (${r.error.code}). Check ops.`
        : r.error.message,
    );
  }

  // 4 — empty state vs list.
  if (r.data.stations.length === 0) {
    root.innerHTML = '<p class="empty">No stations near this VIN.</p>';
    return;
  }

  root.innerHTML = `
    <h1>${ctx.locale === 'ar' ? 'الوقود قريب منك' : 'Fuel near you'}</h1>
    <ul class="stations">
      ${r.data.stations
        .map(
          (s) => `
        <li>
          <span class="name">${escape(s.name)}</span>
          <span class="road">${escape(s.road)}</span>
          <span class="price">${s.price_sar.toFixed(2)} SAR</span>
        </li>`,
        )
        .join('')}
    </ul>
    <footer>${r.data.stations.length} stations · updated just now</footer>
  `;
}

function renderError(msg: string) {
  root.innerHTML = `<p class="error">${escape(msg)}</p>`;
}

function describe(e: unknown): string {
  return e instanceof Error ? e.message : String(e);
}

function escape(s: string): string {
  return s.replace(
    /[&<>"']/g,
    (c) =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!,
  );
}

A few things worth noticing:

  • Loading first. render() paints a loading state synchronously before any async work. The user never sees an empty page.
  • getContext is wrapped in try/catch, but callApi is not. That's deliberate: getContext only throws on real environment problems (no host, malformed schema). callApi returns its failures as data, so we branch on r.success.
  • disallowed_path gets a specific message. It's a config bug, not a user bug — distinguish in the UI so support tickets are actionable.
  • No try/catch around escape(), but we do call it on every string we interpolate. Bundles are public; XSS via a backend that returned <script> is a plausible threat.

3 — Add the fixture

mocks/fuel-stations.GET.json:

{
  "match": { "path": "/api/v1/fuel-stations", "method": "GET" },
  "response": {
    "success": true,
    "data": {
      "stations": [
        { "name": "Aramco", "road": "Olaya Rd",   "price_sar": 2.33 },
        { "name": "Shell",  "road": "King Fahd Rd","price_sar": 2.34 },
        { "name": "ADNOC",  "road": "Tahlia St",  "price_sar": 2.35 }
      ]
    }
  }
}

The dev-server matches by path and method. First match wins; this file alone is enough for the happy path.

To test your error UI without modifying code, add a query-gated fixture:

mocks/fuel-stations.disallowed.GET.json:

{
  "match": {
    "path": "/api/v1/fuel-stations",
    "method": "GET",
    "query": { "simulate": "disallowed" }
  },
  "response": {
    "success": false,
    "error": { "code": "disallowed_path", "message": "not on allow-list" }
  }
}

To trigger it: open the dev URL with ?simulate=disallowed appended, or pass query: { simulate: 'disallowed' } from your code.

The dev-server picks the first fixture whose match is satisfied — sort order is alphabetical, so a more specific filename (fuel-stations.disallowed...) beats the generic one only when the query matches.

5 — Add minimal CSS

src/styles.css:

:root { color-scheme: light dark; }
body  { font-family: system-ui; margin: 0; padding: 16px; }
.loading, .empty, .error { color: #888; }
.error { color: #c44; }
.stations { list-style: none; padding: 0; }
.stations li {
  display: flex; gap: 12px; padding: 12px 0;
  border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
}
.name  { font-weight: 600; }
.road  { color: #888; flex: 1; }
.price { font-variant-numeric: tabular-nums; }
footer { margin-top: 16px; color: #888; font-size: 14px; }

Reference it from src/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.ts"></script>
  </body>
</html>

6 — Run it

pnpm dev

Open http://127.0.0.1:5173 — three stations render. Hit /_sdk/ui and switch the locale to ar — the <html dir> flips to rtl and the heading changes.

To exercise the error path: visit http://127.0.0.1:5173?simulate=disallowed and confirm the error message ends up where you expect.

Going to production with your own backend

The fixture is enough to ship the bundlepnpm publish doesn't care about the backend. But for the published app to actually return real data on a user's car, the host needs to know where to forward each path. Here's the wiring.

Step 1 — stand up the service

Anything that speaks HTTPS works. The contract you have to honour is the CallApiResponse<T> envelope:

// success
{ "success": true, "data": <your payload> }

// failure
{ "success": false, "error": { "code": "<machine code>", "message": "<human>" } }

A minimal Express/Fastify/Hono service that returns the same shape the fixture does:

// server.ts (Hono on Bun / Node)
import { Hono } from 'hono';

const app = new Hono();

app.get('/api/v1/fuel-stations', (c) => {
  const vin = c.req.query('vin');
  return c.json({
    success: true,
    data: {
      stations: [
        { name: 'Aramco', road: 'Olaya Rd',    price_sar: 2.33 },
        { name: 'Shell',  road: 'King Fahd Rd', price_sar: 2.34 },
        { name: 'ADNOC',  road: 'Tahlia St',    price_sar: 2.35 },
      ],
    },
  });
});

export default app;

Deploy on whatever you like (DO App Platform, Vercel, Fly, Cloudflare Workers). Get an HTTPS URL like https://fuel.example.com.

Step 2 — declare the path in your manifest

Your manifest.json doesn't list the URL — it lists the path prefix your app needs:

{
  "id": "fuel-list",
  "name": { "en": "Fuel List" },
  "url": "https://your-cdn.example.com/fuel-list/",
  "version": "0.1.0",
  "category": "info",
  "permissions": [
    "callApi:/api/v1/fuel-stations"
  ]
}

The host honours permissions declarations at install time — users see "this app wants to call /api/v1/fuel-stations" in the consent sheet. Without the permission, the host returns { success: false, error: { code: 'disallowed_path' } } for that path.

Step 3 — coordinate the routing

Send i99dash ops:

  • The path prefix(es) you declared (/api/v1/fuel-stations).
  • The HTTPS URL of your service (https://fuel.example.com).
  • Auth: how should the host pass the user's identity? Two options:
    • Bearer-token forwarding. The host sends a short-lived JWT in Authorization: Bearer ... that your backend verifies via the i99dash JWKS endpoint. Default and recommended.
    • mTLS / shared secret. For internal/enterprise backends. Ops will provision the cert.

Ops adds an entry like:

# host's allow-list (managed centrally; not in your repo)
- pathPrefix: /api/v1/fuel-stations
  upstream: https://fuel.example.com
  forwardAuth: jwt
  ownerApp: fuel-list

After this lands in production config, your published mini-app starts hitting the real backend on next launch. No app rebuild needed — path resolution is host-side.

Step 4 — verify the wire

The host attaches a short-lived JWT for the user's session in the Authorization header. Verify it on your backend before trusting the user identity. Ops will give you the JWKS URL, issuer, and audience values to use:

import { jwtVerify, createRemoteJWKSet } from 'jose';

// Values supplied by ops when they add your app to the routing config.
const JWKS_URL = process.env.I99DASH_JWKS_URL!;
const ISSUER   = process.env.I99DASH_ISSUER!;
const AUDIENCE = 'fuel-list'; // your manifest id

const JWKS = createRemoteJWKSet(new URL(JWKS_URL));

app.get('/api/v1/fuel-stations', async (c) => {
  const auth = c.req.header('Authorization');
  if (!auth?.startsWith('Bearer ')) {
    return c.json(
      { success: false, error: { code: 'unauthorized', message: 'missing bearer' } },
      401,
    );
  }
  const { payload } = await jwtVerify(auth.slice(7), JWKS, {
    issuer: ISSUER,
    audience: AUDIENCE,
  });
  // payload.sub = userId, payload.vin = activeCarId, payload.locale = ...
  // ...
});

Now when your mini-app's callApi hits the host, the host attaches a JWT signed for this user's session in your specific app, and your backend can trust the user identity without the mini-app needing to manage tokens.

Step 5 — test the production path locally

You don't need to wait for the host to route prod traffic to your service. Two patterns:

  • Continue using fixtures for unit/integration tests. Fast, deterministic, no network.
  • Point the dev-server at your real backend by writing a fixture that proxies — there's a passthroughUrl option in sdk.config.json for that. See Local development.

Once the production deployment is wired (step 3), publish your mini-app and traffic flows to your service automatically.

What you didn't have to do

  • Auth. The host injects the user's session token; you don't manage credentials.
  • CORS. The host is the network egress; CORS is its problem.
  • Origin handling. Your path is host-relative — the allow-list decides where it resolves.
  • Schema validation of the envelope. callApi validates { success, data | error } shape via zod before returning. A malformed payload throws InvalidResponseError, which you can treat as "host bug" and report.

Where to go next

On this page