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:
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.iwcStep 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:
{
"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:
{
"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
{
"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
{
"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.querydeclares?base=with its allowed values and default.response.fieldsis what clients — and the page — receive.cache: "5m"keeps each answer for five minutes.
Step 4: Show it on a page
{
"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.erroris shown when the API fails, instead of breaking the page.
Step 5: Test both paths
{
"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 } }
]
}iwc test ✓ 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 failedGoing further
@iwc/*packages (mail, payments, maps, AI…) are installed withiwc addand 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
| Problem | Fix |
|---|---|
The page shows the error element | Run iwc logs — the failed call and its reason are logged. |
production refuses to start with a simulated service | Switch the service to "provider": "http" and set its environment variables. |
baseUrl rejected | It must be https://, or http:// to localhost only. |
Source: apps/marketing/content/docs/tutorials/connect-an-api.md