voxal docs
Guides

Fetch data from an API

Call external HTTP APIs from a voxal app with the host-bridged fetch, including JSON, errors, and limits

Voxal apps run inside a sandbox with no network access of their own. To reach the outside world you import fetch from @voxalsh/sdk, which runs the request in the trusted host and hands the result back to your app. This guide covers GET and POST, parsing JSON, handling errors, showing a loading state, and the limits you need to design around.

The shape of fetch

import { fetch } from '@voxalsh/sdk';

const res = await fetch(url, { method, headers, body });
// res: { status, headers, body }

Two things differ from the browser fetch:

  • The request body, if present, must be a string. Serialize JSON yourself with JSON.stringify.
  • The result body is always a UTF-8 string, not a stream or a parsed object. Call JSON.parse(res.body) to get JSON.

A GET request

app.js
import { createApp, fetch } from '@voxalsh/sdk';

createApp()
  .onKey(async (conn, data) => {
    if (data !== 'c') return;
    try {
      const res = await fetch('https://catfact.ninja/fact');
      const fact = JSON.parse(res.body).fact;
      conn.write('🐱 ' + fact + '\r\n');
    } catch (err) {
      conn.write('(fetch failed: ' + err.message + ')\r\n');
    }
  })
  .listen();

The SDK does not await your handlers, so always wrap an await fetch(...) in try/catch. An unhandled rejection is logged to the server logs, not shown to the user, so they would see nothing happen.

A POST request with JSON

Set the method, headers, and a stringified body.

app.js
const res = await fetch('https://api.example.com/items', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ name: 'widget', qty: 3 }),
});

Handling non-2xx responses

A non-2xx status does not throw. Only network-level failures (timeout, blocked address, transport error) reject the promise. Check res.status yourself.

app.js
const res = await fetch('https://api.example.com/data');

if (res.status >= 200 && res.status < 300) {
  const data = JSON.parse(res.body);
  // use data
} else {
  conn.write('request failed with status ' + res.status + '\r\n');
}

A loading state in a UI app

In the UI layer, track a busy flag in state, set it before the request, and clear it in a finally so it resets even on error.

app.jsx
import React, { useState } from 'react';
import { serve, Box, Text, useInput } from '@voxalsh/sdk/ui';
import { fetch } from '@voxalsh/sdk';

function Fact() {
  const [fact, setFact] = useState('press f for a cat fact');
  const [busy, setBusy] = useState(false);

  const load = async () => {
    setBusy(true);
    try {
      const res = await fetch('https://catfact.ninja/fact');
      setFact(JSON.parse(res.body).fact);
    } catch (err) {
      setFact('(failed: ' + err.message + ')');
    } finally {
      setBusy(false);
    }
  };

  useInput((input) => {
    if (input === 'f' && !busy) load();
  });

  return (
    <Box borderStyle="round" paddingX={1}>
      <Text>{busy ? '…fetching' : fact}</Text>
    </Box>
  );
}

serve(() => <Fact />);

Limits

fetch runs from the host, so it is guarded and rate-limited. Design for these:

LimitValue
Schemeshttp and https only
Blocked targetsprivate, loopback, link-local, and cloud-metadata addresses
Redirectsup to 5 hops
Timeout10 seconds per request
Response size5 MiB cap
Rate30 requests per 10 seconds per app
Concurrency6 in flight per app

The blocked-address rules cannot be turned off; they exist so that a deployed app cannot probe the host's internal network. If you need a request to a private service, it has to be reachable on a public address.

Next steps

On this page