Skip to content
ONIWC
iwc 0.1.0

Tutorial 6: Connect an API

Objective: show data from an external HTTP API on a page — exchange rates — with caching, a clean error state, and a simulated provider so everything runs locally without an account.

Prerequisites: Tutorial 5.

The path data takes: page → API.rates data source → GET API → action → call step → service. Each piece is a small file:

Project files
api-connect/
├── actions/
│   └── get-rates.action.iwc
├── api/
│   └── rates.api.iwc
├── services/
│   └── rates.service.iwc
├── styles/
│   └── main.css
├── tests/
│   └── rates.test.iwc
├── ui/
│   └── home.ui.iwc
├── app.iwc
└── package.iwc

Step 1: Declare the external service

A .service.iwc describes an external API once: its address, credentials and methods. During development we use a simulated provider whose answers are expressions over the input:

services/rates.service.iwc
{
  "name": "Rates",
  "provider": "simulated",
  "methods": {
    "latest": {
      "respond": {
        "base": "=input.base",
        "date": "2026-11-09",
        "usd": "=if(input.base == 'INR', 0.0119, 1)",
        "eur": "=if(input.base == 'INR', 0.0103, 0.8652)",
        "gbp": "=if(input.base == 'INR', 0.0091, 0.7614)"
      },
      "fail": "input.base == 'XXX'"
    }
  }
}

fail makes the call fail (here, for the made-up currency XXX), so the error path can be tested too. Production refuses to start with a simulated service unless it opts in, so a fake can't ship by accident.

When you have a real provider, the same service becomes "provider": "http" with its address and key — the rest of the project does not change:

.service.iwc
{
  "name": "Rates",
  "provider": "http",
  "baseUrl": { "env": "RATES_URL" },
  "auth": { "type": "header", "header": "X-Api-Key", "secret": { "env": "RATES_KEY" } },
  "methods": {
    "latest": { "method": "GET", "path": "/latest", "query": { "base": "=input.base" }, "cache": "5m" }
  }
}

Credentials are always {"env": "NAME"}: a literal key in a file is a compile error.

Step 2: An action that calls it

actions/get-rates.action.iwc
{
  "name": "GetRates",
  "description": "Fetches today's rates for a base currency from the rates service.",
  "input": { "base": { "type": "string", "default": "INR" } },
  "steps": [
    { "operation": "call", "service": "rates.service.iwc", "method": "latest", "input": { "base": "=input.base" }, "as": "r", "status": 502, "error": "The rates service is unavailable" },
    { "operation": "return", "value": { "base": "=r.base", "date": "=r.date", "usd": "=r.usd", "eur": "=r.eur", "gbp": "=r.gbp" } }
  ]
}

The call step stores the parsed response in r. If the service fails (non-2xx, timeout, network error), the step fails with the status and error you gave it, and the action stops.

Step 3: A GET API in front of the action

api/rates.api.iwc
{
  "name": "rates",
  "method": "GET",
  "path": "/rates",
  "description": "Today's exchange rates for a base currency.",
  "action": "get-rates.action.iwc",
  "request": { "query": { "base": { "type": "string", "enum": ["INR", "USD", "XXX"], "default": "INR" } } },
  "response": { "fields": { "base": "string", "date": "string", "usd": "number", "eur": "number", "gbp": "number" } },
  "cache": "5m"
}
  • request.query declares ?base= with its allowed values and default.
  • response.fields is what clients — and the page — receive.
  • cache: "5m" keeps each answer for five minutes.

Step 4: Show it on a page

ui/home.ui.iwc
{
  "name": "Rates",
  "route": "/",
  "title": "Exchange rates",
  "sections": [
    {
      "type": "section",
      "className": "board",
      "layout": { "direction": "column", "gap": "20px" },
      "elements": [
        { "type": "text", "variant": "h1", "content": "Exchange rates" },
        {
          "type": "row",
          "layout": { "gap": "8px" },
          "elements": [
            { "type": "link", "label": "From INR", "target": "/?base=INR", "className": "chip" },
            { "type": "link", "label": "From USD", "target": "/?base=USD", "className": "chip" }
          ]
        },
        {
          "type": "column",
          "className": "panel",
          "data": { "source": "API.rates", "mode": "single", "query": { "base": { "query": "base" } } },
          "elements": [
            { "type": "text", "variant": "caption", "bind": "date", "prefix": "Rates for " },
            { "type": "text", "variant": "h3", "bind": "base", "prefix": "1 " },
            { "type": "text", "bind": "usd", "prefix": "US dollar: " },
            { "type": "text", "bind": "eur", "prefix": "Euro: " },
            { "type": "text", "bind": "gbp", "prefix": "Pound sterling: " }
          ],
          "error": { "type": "text", "content": "Rates are unavailable right now. Please try again later." }
        }
      ]
    }
  ]
}
  • "source": "API.rates" runs the API internally, with the visitor's session and all its validation.
  • "mode": "single" binds one record to the children (the API returns an object, not a list).
  • "query": {"base": {"query": "base"}} forwards the page's ?base= to the API.
  • error is shown when the API fails, instead of breaking the page.

Step 5: Test both paths

tests/rates.test.iwc
{
  "name": "rates",
  "cases": [
    { "name": "the API returns the service's rates", "request": { "path": "/api/rates?base=USD" }, "expect": { "status": 200, "body": { "base": "USD", "eur": 0.8652 } } },
    { "name": "the page shows rates for the base in the URL", "request": { "path": "/?base=USD" }, "expect": { "status": 200, "contains": ["1 USD", "Euro: 0.8652"] } },
    { "name": "the default base is INR", "request": { "path": "/" }, "expect": { "status": 200, "contains": ["1 INR"] } },
    { "name": "a failing service becomes a 502 for API clients", "request": { "path": "/api/rates?base=XXX" }, "expect": { "status": 502 } },
    { "name": "and the error element on the page", "request": { "path": "/?base=XXX" }, "expect": { "status": 200, "contains": ["Rates are unavailable right now."] } },
    { "name": "unknown currencies are rejected", "request": { "path": "/api/rates?base=ABC" }, "expect": { "status": 422 } }
  ]
}
Terminal
iwc test
Output
  ✓ rates › the API returns the service's rates
  ✓ rates › the page shows rates for the base in the URL
  ✓ rates › the default base is INR
  ✓ rates › a failing service becomes a 502 for API clients
  ✓ rates › and the error element on the page
  ✓ rates › unknown currencies are rejected

6 passed, 0 failed

Going further

  • @iwc/* packages (mail, payments, maps, AI…) are installed with iwc add and called the same way, with "integration" instead of "service". See Packages & services.
  • Outbound calls go through IWC's egress guard: an address that resolves to an internal network is refused (SSRF protection), and redirects are not followed.

Troubleshooting

ProblemFix
The page shows the error elementRun iwc logs — the failed call and its reason are logged.
production refuses to start with a simulated serviceSwitch the service to "provider": "http" and set its environment variables.
baseUrl rejectedIt must be https://, or http:// to localhost only.

Next: Build an authentication system.

Source: apps/marketing/content/docs/tutorials/connect-an-api.md