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 prefixThe 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:
- Stand up an HTTPS service that returns
{ success, data | error }envelopes. - Declare the path prefix in
manifest.permissions[], e.g."callApi:/api/v1/my-feature". - 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
};| Field | Required? | Notes |
|---|---|---|
path | yes | Must match a pattern on the host's allow-list. Origin is implied — you don't send a full URL. |
method | yes | GET only in v1. Read-only design — see Why no POST/PUT/DELETE. |
query | no | Plain object. Numbers are stringified; arrays serialise as key=v1&key=v2. |
headers | no | Forwarded 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.code | What it means | What to render |
|---|---|---|
disallowed_path | The host rejected your path. The allow-list doesn't include it. | Log + report — this is a config bug, not a user bug. |
http_4xx | Backend returned a 4xx (typically auth or input). Body is in r.error.message. | Surface the backend's message to the user. |
http_5xx | Backend returned a 5xx. | Generic "service unavailable, try again". |
network_error | The host couldn't reach the backend at all. | "No connection" UI. |
timeout | Request 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
callApipaths as a public surface. If a path leaks more than the user should see, it's a backend bug, not a client one.
Related
callApireference — type details.CallApiRequest— wire schema.CallApiResponse— envelope.- Best practices — production error-handling patterns.
- Error model — when callApi throws vs returns a failure envelope.