> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Network access

> Call external HTTP services from inside a function

# Network access

Functions are sandboxed by default — no network, no filesystem, no
ambient time. Opt in to outbound HTTP per-function by setting
`network_access: true` on the manifest and listing the hosts you need
in `allowed_hosts`.

<Warning>
  **Status — partial wiring.** Manifest validation, the host allowlist
  enforcement, outbound-call telemetry, and the `lms_host.fetch_url`
  host import are **live**. The JS-side runtime shim that exposes this
  import as `globalThis.fetch` from user code is **not yet wired** in the
  current dynamic-mode (Javy `-d`) build — a function that calls
  `globalThis.fetch(...)` will throw `'fetch' is not defined` because Javy
  ships QuickJS without the Fetch API.

  Until the shim ships, build functions that work without outbound HTTP.
  The manifest fields documented below are accepted (and enforced
  server-side) so you can declare intent today; outbound calls will start
  working once the runtime shim lands **without any manifest change**.
</Warning>

## When to use it

Most functions should **not** need network access. The platform supplies
cart, customer, metafields, locations, shop currency, and addresses
inline — that covers 95% of real-world function logic. Reach for
network access only when:

* You need a **real-time lookup** that the merchant can't pre-compute
  (live currency rate, live tax table, stock at a 3PL).
* You're integrating with an **external service** whose data isn't
  appropriate to mirror into metafields (fraud score, loyalty tier,
  carrier rate).
* You need to **side-effect** to your own backend (write an analytics
  event when a function fires — though prefer the host's audit log when
  possible).

If you find yourself reaching for network access because the data is
"convenient" to fetch live, consider mirroring it into a metafield
instead. Function dispatch happens on every cart change and every
checkout transition — a slow outbound call shows up as a slow cart.

## Manifest

```json theme={null}
{
  "handle": "loyalty-bonus",
  "name": "Loyalty Bonus",
  "version": "1.0.0",
  "extensions": {
    "functions": [
      {
        "type": "discount",
        "handle": "loyalty-tier-bonus",
        "entrypoint": "dist/loyalty.wasm",
        "network_access": true,
        "allowed_hosts": [
          "api.loyalty.example.com",
          "edge.loyalty.example.com"
        ]
      }
    ]
  }
}
```

`network_access` and `allowed_hosts` apply per function (not per app),
so a single app can have one function with network access and others
without.

### Field reference

| Field            | Required                            | Type       | Description                                               |
| ---------------- | ----------------------------------- | ---------- | --------------------------------------------------------- |
| `network_access` | no                                  | `boolean`  | Opt-in flag. Defaults to `false` (sandboxed, no network). |
| `allowed_hosts`  | yes when `network_access` is `true` | `string[]` | Allowlist of hostnames the function may reach.            |

`allowed_hosts` entries are **hostnames only** — no scheme, no port, no
path, no wildcards. The install endpoint rejects malformed entries
with HTTP 400:

| Manifest                            | Outcome                                                  |
| ----------------------------------- | -------------------------------------------------------- |
| `"api.loyalty.example.com"`         | OK                                                       |
| `"loyalty.example.com"`             | OK                                                       |
| `"127.0.0.1"`                       | Rejected — not a valid public hostname pattern.          |
| `"https://api.loyalty.example.com"` | Rejected — scheme not allowed.                           |
| `"api.loyalty.example.com/v1"`      | Rejected — path not allowed.                             |
| `"api.loyalty.example.com:8443"`    | Rejected — port not allowed.                             |
| `"*.loyalty.example.com"`           | Rejected — wildcards not supported.                      |
| `"localhost"`                       | Rejected — single-label hosts rejected by the validator. |

The validator regex requires at least one `.` and each label to match
`[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?` (case-insensitive). This is
intentional: an allowlist is **not** a regex — every host you want to
reach must be enumerated.

### Install-time validation

The LaunchMyStore install endpoint (`/api/apps/install-extensions`) runs
the same validator as the dispatcher before persisting the manifest.
Errors block install with a clear message:

```
HTTP/1.1 400 Bad Request
{ "error": "Invalid function manifest for \"loyalty-tier-bonus\": allowed_hosts entry \"https://api.loyalty.example.com\" is not a valid hostname" }
```

Fix the manifest and re-call install. There is no separate "validate"
endpoint — the install path is the contract.

## Runtime API

Your WASM module gets a single synchronous host import (exposed by the
Javy runtime, not part of the WASI spec):

```
lms_host.fetch_url(urlPtr, urlLen, optsPtr, optsLen) -> int32
```

The bundled JS runtime will expose this as `globalThis.fetch` once the
shim lands — call it exactly as you would in Node:

```javascript theme={null}
export default async function main(input) {
  const customer = input.customer;
  if (!customer?.id) return { discounts: [] };

  // (Once the runtime shim is wired.)
  const r = await fetch(
    `https://api.loyalty.example.com/customers/${customer.id}/tier`,
  );
  if (!r.ok) return { discounts: [] };

  const { tier } = await r.json();
  if (tier !== 'platinum') return { discounts: [] };

  return {
    discounts: [
      { title: 'Platinum loyalty bonus', value: 15, valueType: 'percentage', target: 'order', targetSelection: 'all' },
    ],
  };
}
```

### Limits

| Limit                | Value        | Notes                                                                     |
| -------------------- | ------------ | ------------------------------------------------------------------------- |
| Response body size   | 64 KB        | Excess is truncated. The function gets only the first 64 KB.              |
| Total time per call  | 1500 ms      | Includes DNS + TCP + TLS + read. After timeout, the call fails with `-4`. |
| Allowed schemes      | `https` only | `http://` is rejected at runtime even if the host is in `allowed_hosts`.  |
| Concurrent calls     | 1            | Calls are serialized within a single function invocation.                 |
| Calls per invocation | 4            | After 4 successful (or failed) calls, subsequent calls fail with `-5`.    |

These limits are platform-enforced — your function cannot raise them.
If you need a longer call, do the slow work async on your own service
and return a cached answer from a fast endpoint.

### Return codes

`lms_host.fetch_url` returns an `int32` to your WASM module. The bundled
runtime shim translates these to fetch errors / response objects:

| Code   | Meaning                                                                         | Action                                                   |
| ------ | ------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `-1`   | `network_access` not enabled on this function.                                  | Manifest bug — set `network_access: true` and reinstall. |
| `-2`   | URL was malformed (couldn't parse, invalid scheme, etc.).                       | Fix the URL your function builds.                        |
| `-3`   | Host not in `allowed_hosts`.                                                    | Add the host to the manifest's `allowed_hosts` list.     |
| `-4`   | Request timed out (>1500 ms) or transport error (DNS, TLS, connection refused). | Make the upstream faster or fail open.                   |
| `-5`   | Per-invocation call cap (4) reached.                                            | Reduce the number of outbound calls.                     |
| `>= 0` | Number of bytes written to the response buffer.                                 | Read the buffer; call succeeded.                         |

`-3` is the most common error in practice: a developer copy-pastes a
URL with a slightly different host (`api.example.com` vs
`api.staging.example.com`) and forgets to add the new host to
`allowed_hosts`. The error message logged on the function run row
includes the offending host so this is quick to debug.

## Dispatch behaviour when a fetch is blocked

When a fetch fails with any of `-1` through `-5`, **the function still
runs to completion** — only the specific `fetch_url` call returns
failure. Your function decides how to react:

```javascript theme={null}
const r = await fetch('https://api.loyalty.example.com/tier');
if (!r.ok || r.status >= 500) {
  // Fail open: skip the bonus, don't break the cart.
  return { discounts: [] };
}
```

If you `throw` from the function instead of returning, the dispatcher
treats it like any other WASM error: the result is **discarded** (no
output applied to the cart / checkout) and an error is logged to the
function run row. Other apps' functions of the same type still run —
one bad app cannot take the whole pipeline offline.

The platform never auto-retries blocked or timed-out fetches. If you
need retries, build them into the function:

```javascript theme={null}
async function fetchWithRetry(url, retries = 1) {
  for (let i = 0; i <= retries; i++) {
    const r = await fetch(url);
    if (r.ok) return r;
    if (r.status < 500) return r; // 4xx — don't retry
  }
  return null;
}
```

…but be mindful of the 1500 ms total-time budget across all calls.

## TLS / CORS notes

* **TLS is required.** The runtime only allows `https://` URLs even if
  the host is in `allowed_hosts`. There is no plaintext escape hatch.
* **No CORS.** The host import is a server-to-server fetch initiated by
  the WASM worker process — there is no browser, no preflight, no
  `Access-Control-Allow-Origin` to consider. Your endpoint can be
  locked down to `Origin: lms-functions.launchmystore.io` if you need
  to verify the caller.
* **No cookies.** The runtime doesn't carry merchant/customer cookies
  on outbound calls. Authenticate with an API key or Bearer token your
  app already has.
* **No streaming.** Response bodies are fully buffered (capped at 64 KB)
  before the function sees them. There is no `ReadableStream`.

## Authenticating outbound calls

The function runtime doesn't have a built-in secret store. You have two
practical options:

1. **Bake a key into the WASM bundle.** Simplest for first-party apps —
   the key sits in your compiled module and lands on the merchant's
   disk only as part of your signed bundle. Risk: anyone who pulls the
   WASM apart can read the key.
2. **Embed the key in a merchant-specific app metafield** that the
   function reads from `cart.metafields.app_<handle>.api_key.value`.
   The merchant can rotate the key independently of the function
   binary.

Option 2 is recommended for production. Future releases will add a
proper per-merchant secret API.

## Observability

Every outbound call is recorded on the function run's `meta` object
(visible via the [function runs API](/api-reference/apps/function-runs)):

```json theme={null}
{
  "networkAccess": true,
  "outboundCalls": [
    { "host": "api.loyalty.example.com", "status": 200, "bytes": 482, "ms": 134 },
    { "host": "api.loyalty.example.com", "status": 502, "bytes": 0,   "ms": 1502, "errorCode": -4 }
  ]
}
```

Merchants (in the app management UI) and you (in the developer portal)
can audit which hosts each function call actually reached, the status
codes, and the latency. This is the first place to look when a function
behaves differently in production than in test.

For aggregate health, the developer portal surfaces:

* **Calls per function per day** — to track usage.
* **p50 / p95 outbound latency** — to spot slowness before it shows up
  as checkout latency.
* **Failure rate by error code** — to catch new `-3` host-allowlist
  errors after a deploy.

## Common patterns

### Real-time rate API

```javascript theme={null}
// type: shipping_rate
export default async function main(input) {
  const r = await fetch(
    `https://rates.carrier.example.com/quote?zip=${input.destination.zip}&weight=${cartWeight(input.cart)}`,
    { headers: { 'X-Api-Key': API_KEY } },
  );
  if (!r.ok) {
    // Fail open with a default rate so the cart doesn't break.
    return { rates: [{ name: 'Standard', price: 9.99 }] };
  }
  const { rates } = await r.json();
  return { rates };
}
```

### Geo-IP / fraud check

```javascript theme={null}
// type: order_validation
export default async function main(input) {
  const r = await fetch(
    `https://fraud.example.com/score?country=${input.shippingAddress.country}&total=${input.cart.totalPrice}`,
  );
  if (!r.ok) return { errors: [] }; // fail open

  const { score, reason } = await r.json();
  if (score > 0.9) {
    return {
      errors: [
        { message: `Order flagged for review: ${reason}`, target: 'cart' },
      ],
    };
  }
  return { errors: [] };
}
```

### Loyalty tier lookup

```javascript theme={null}
// type: discount
export default async function main(input) {
  const id = input.customer?.id;
  if (!id) return { discounts: [] };

  const r = await fetch(
    `https://api.loyalty.example.com/customers/${id}/tier`,
    { headers: { 'Authorization': `Bearer ${API_KEY}` } },
  );
  if (!r.ok) return { discounts: [] };

  const { tier } = await r.json();
  const rate = { gold: 5, platinum: 10, diamond: 15 }[tier] || 0;
  if (rate === 0) return { discounts: [] };

  return {
    discounts: [{
      title: `${tier} loyalty (${rate}%)`,
      value: rate,
      valueType: 'percentage',
      target: 'order',
      targetSelection: 'all',
    }],
  };
}
```

## Security considerations

* **`allowed_hosts` is an allowlist, not a regex.** Every host you want
  to reach must be enumerated by exact match. This is intentional — a
  regex allows escalation by typo, an allowlist doesn't.
* **Don't include `localhost` or private IP ranges.** The validator
  rejects single-label hosts and IPs, and the runtime additionally
  refuses RFC1918 ranges and link-local addresses even if you somehow
  declared them. The platform does not let WASM functions probe the
  internal network.
* **Treat your endpoint as untrusted-input territory.** Anyone who can
  install your app on a merchant can trigger calls to your allowed
  hosts. Rate-limit by API key, log every call, and refuse requests
  with suspicious payloads.
* **Cache aggressively on your side.** Returning a cached response for
  the same `(customer, cart)` tuple keeps your function fast and your
  upstream costs predictable.
* **Rotate API keys regularly.** Use the metafield-based pattern (see
  [Authenticating outbound calls](#authenticating-outbound-calls)) so
  rotation doesn't require a WASM rebuild.

## Best practices

* **Short timeouts on your side too.** Your endpoint should fail in
  \<500 ms 99% of the time. The platform's 1500 ms hard timeout is a
  safety net, not a target.
* **Return structured errors.** A 200 with `{ "ok": false, "reason": ... }`
  is faster than a 5xx because the runtime doesn't have to retry.
* **Cache aggressively.** Each cart-verification and order-placement call can trigger
  function dispatch — if your endpoint is slow or rate-limited you'll
  cause cart latency that shows up in conversion metrics.
* **Keep allow-lists tight.** A single misconfigured host can leak data
  if your function reads sensitive merchandise attributes.
* **Decide fail-open vs fail-closed deliberately.** On `-4` (timeout),
  decide whether your function should return a no-op output (fail open
  — preserve cart usability) or surface an error (fail closed — block
  the bad state). Most discount/shipping functions should fail open;
  order-validation and fulfillment-constraints functions might fail
  closed.
* **Log the request id.** Pass the `cart.id` or `customer.id` as a
  header so your upstream logs can be correlated with the function run
  in the developer portal.

## See also

* [Input field selection](/functions/input-fields) — the other
  manifest-level field that shapes function runtime behaviour.
* [Discount functions](/functions/discount),
  [Shipping rate functions](/functions/shipping-rate),
  [Order validation](/functions/order-validation) — common consumers of
  network access.
* [App metafields](/aqua/metafields) — for storing per-merchant API
  keys outside the WASM bundle.
