i99dash docs
Concepts

Calling your backend

The `callApi()` mental model. Why it isn't `fetch()`. How the host's allow-list works. The two failure shapes.

client.callApi() is the proxy your mini-app uses to reach an HTTP backend. It looks like fetch() but isn't — there are two important differences worth internalising before you write your first call.

The two big differences from fetch()

1. The host has an allow-list

Your mini-app cannot make arbitrary HTTP requests. Every callApi() request is dispatched through the host, which checks the target path against an allow-list before forwarding it. Paths not on the list return { success: false, error: { code: 'disallowed_path' } }.

Why: a public mini-app bundle is downloaded by every user. If it could reach evil.example.com/exfil, every user's car becomes a data-exfiltration vector. The allow-list closes that.

path is not a URL — it's a route key

client.callApi({ path: '/api/v1/fuel-stations', method: 'GET' });
//                    ^─ NOT a URL.
//                    The host resolves this at call time:
//
//                    Local dev:   a JSON fixture in mocks/
//                    Production:  your HTTPS backend, mapped by
//                                 i99dash ops to the path prefix

The mapping table lives on the host side. Your mini-app's code does not change between dev and prod — same path, the host swaps what it forwards to.

You bring your own backend

The i99dash platform's own backend API is internal infrastructure for the host itself — not a developer surface. There is no shared "platform endpoint" you can call for free. Every mini-app's callApi paths resolve to either a fixture (in dev) or a service the developer brought (in prod).

The flow:

  1. Stand up an HTTPS service that returns { success, data | error } envelopes.
  2. Declare the path prefix in manifest.permissions[], e.g. "callApi:/api/v1/my-feature".
  3. Coordinate with i99dash ops: send them the path prefix + your service URL. Ops adds the mapping to the host's allow-list.

Your app only declares what paths it needs — the mapping to where they live is host config, controlled centrally. You never put a service URL in the mini-app's code or in manifest.json.

Full walkthrough with a runnable Hono example: Recipe — Going to production with your own backend.

2. Failures are data, not exceptions

// Wrong — assumes success or throws on failure
try {
  const r = await client.callApi({ path: '/api/v1/x', method: 'GET' });
  return r.data; // ← what if success was false?
} catch {
  return null;
}

// Right — branch on the envelope
const r = await client.callApi({ path: '/api/v1/x', method: 'GET' });
if (!r.success) {
  // r.error.code is one of: disallowed_path, http_4xx, http_5xx,
  // network_error, timeout, ...
  return handleFailure(r.error);
}
return r.data;

callApi() only throws for genuine transport failures (bridge crashed, timeout exceeded, malformed envelope from the host). Every documented failure mode — disallowed path, backend 4xx, backend 5xx, network down, timeout — comes back as { success: false, error: ... }.

This is intentional. try/catch for branching makes happy-path unreadable; branching on success is symmetric and forces you to think about the failure case.

Anatomy of a request

import { type CallApiRequest } from '@i99dash/sdk';

const req: CallApiRequest = {
  path: '/api/v1/fuel-stations',  // path the host's allow-list checks
  method: 'GET',                   // 'GET' is the only verb in v1
  query: { lat: 24.7, lng: 46.6 }, // optional; serialised as URL query
  headers: { 'X-Trace': 'abc' },   // optional; forwarded to backend
};
FieldRequired?Notes
pathyesMust match a pattern on the host's allow-list. Origin is implied — you don't send a full URL.
methodyesGET only in v1. Read-only design — see Why no POST/PUT/DELETE.
querynoPlain object. Numbers are stringified; arrays serialise as key=v1&key=v2.
headersnoForwarded to the backend. Don't put secrets here — see The bundle is public.

The host adds the rest:

  • The user's session token (you don't manage auth tokens client-side).
  • A correlation ID for tracing.
  • Origin headers the backend can use to enforce its own rules.

Anatomy of a response

import { type CallApiResponse } from '@i99dash/sdk';

type CallApiResponse<T = unknown> =
  | { success: true; data: T }
  | { success: false; error: { code: string; message: string } };

The discriminant is success. TypeScript narrows correctly:

const r = await client.callApi<{ stations: Station[] }>({
  path: '/api/v1/fuel-stations',
  method: 'GET',
});

if (r.success) {
  // TypeScript knows r.data is { stations: Station[] }
  renderStations(r.data.stations);
} else {
  // TypeScript knows r.error.code is string
  if (r.error.code === 'disallowed_path') reportConfigBug();
  else renderRetry();
}

Common error codes

r.error.codeWhat it meansWhat to render
disallowed_pathThe host rejected your path. The allow-list doesn't include it.Log + report — this is a config bug, not a user bug.
http_4xxBackend returned a 4xx (typically auth or input). Body is in r.error.message.Surface the backend's message to the user.
http_5xxBackend returned a 5xx.Generic "service unavailable, try again".
network_errorThe host couldn't reach the backend at all."No connection" UI.
timeoutRequest exceeded the host's timeout (separate from the SDK's timeout).Same as network_error for users; log separately for ops.

Backend-specific codes (e.g. fuel.station_not_found) are also possible when your backend responds with a 4xx and a structured body. Forward those to your error-rendering code as-is.

Patterns

Polling

async function poll<T>() {
  while (!cancelled) {
    const r = await client.callApi<T>({ path: '/api/v1/x', method: 'GET' });
    if (r.success) update(r.data);
    await sleep(5_000);
  }
}

callApi's default 10-second timeout means a hung request only blocks the loop for that long. No backpressure / dedup logic needed for poll-rate ≥ 10s.

Cancellation on unmount

useEffect(() => {
  const controller = new AbortController();
  client.callApi({ path: '/api/v1/x', method: 'GET' }, { signal: controller.signal })
    .then(r => { if (r.success) setData(r.data); });
  return () => controller.abort();  // ← cancels in-flight on unmount
}, []);

The signal is forwarded to the bridge. Aborting before the host responds rejects with the signal's reason (DOM-standard AbortError).

Custom timeout

const r = await client.callApi(
  { path: '/api/v1/big-report', method: 'GET' },
  { timeoutMs: 60_000 }, // 60s for a slow report endpoint
);

Defensive read on the response shape

Backends evolve. A field you depend on might be optional in the schema:

const r = await client.callApi<{ stations?: Station[] }>(req);
if (r.success) {
  for (const s of r.data.stations ?? []) {
    // ...
  }
}

callApi validates the envelope (the success/data shape). It does not validate data against your T — that's your contract with your backend. Use zod on the client if you want runtime validation.

Why no POST/PUT/DELETE

The SDK is read-only by construction, same reason there's no client.car.lockDoors(). Mutations require a different review and permission model than reads. If a future SDK version supports a write surface, it will be a separate explicit method, not a flag on callApi.

The bundle is public

Anything you embed in your bundle — API keys, header secrets, logic — is visible to every user who installs your mini-app. unzip + strings extracts it.

This means:

  • Don't put auth tokens in headers. The host injects the session token; that's the only auth you need.
  • Don't put database credentials, Stripe keys, or service-role tokens anywhere in your code. Fetch them server-side after the user authenticates.
  • Treat your callApi paths as a public surface. If a path leaks more than the user should see, it's a backend bug, not a client one.

On this page