---
title: Consume an external feed
description: Read a remote JSON endpoint at render time with an `api` field, key it on the item the page is showing, and handle the day it stops answering — which is the part that actually needs designing.
updated: 2026-08-20
---

A site often needs data it does not own: a weather forecast, exchange rates, availability from a booking system, a partner's catalogue. Quilium reads it with a single field type — `api` — declared in the YAML and resolved by the engine before your template runs. **The template makes no network call.** It reads `page.custom.<field>` exactly as it reads a text field.

That much is easy. The two things worth getting right are what the URL is allowed to know, and what the page looks like when the remote is down.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Cairn.</b> Everything on this page was measured on <b>Cairn</b>, the demo site of a hiking-route publisher. Each route's detail page shows a three-day forecast <b>at that route's own trailhead</b>, pulled from <a href="https://open-meteo.com">Open-Meteo</a> — a free, keyless, public endpoint.</div></div>

## 1. Key the URL on the item the page is showing

This is the part that makes an `api` field useful rather than decorative, so it comes first.

Cairn's detail page-type already carries a `query` field named `itinerary`, which resolves the route from the URL slug. The `api` field sits next to it and **reads that query's result straight out of the URL**:

```yaml
itineraireDetail:
  name: Itinerary (detail)
  template: itineraireDetail.liquid
  tabsets:
    config:
      name: Data
      fields:
        itinerary:
          type: query
          label: Current itinerary
          config:
            itemSet: itineraires
            limit: 1
            where:
              '&=':
                slug: '{{route.itinerary.slug}}'
        weather:
          type: api
          label: Weather at the start point (Open-Meteo)
          config:
            format: json
            timeout: 5
            caching:
              enabled: true
              validity: 60
            src: 'https://api.open-meteo.com/v1/forecast?latitude={{itinerary.coords.lat}}&longitude={{itinerary.coords.lng}}&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=Europe%2FParis&forecast_days=3'
```

`{{itinerary.coords.lat}}` is the resolved route's `geo` field, reached through **the query field's own name, used bare as a namespace**. Eight routes, eight trailheads, eight different forecasts — Les Clots at 1 429 m, Cervières at 1 968 m, Chamrousse at 2 044 m — from one line of configuration.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Three near-miss spellings, all of which fail silently.</b> <code>{{itinerary[0].coords.lat}}</code> — the indexed form the <i>template</i> uses — does not resolve. Neither does <code>{{page.custom.itinerary[0].coords.lat}}</code>. Neither does <code>{{field.itinerary.coords.lat}}</code>: <code>field.</code> is for values an <b>editor typed</b> in another field of the same block, not for a query's result. All three yield an empty object and a page that returns 200. The working form is the bare field name: <code>{{itinerary.coords.lat}}</code>.</div></div>

`dependsOn` is not the answer either — it exists on `query` fields, and an `api` field refuses it outright at validation: *"A Api field can't have a 'dependsOn' property."*

### The variables `src` accepts

| Variable | Comes from | Measured |
|---|---|---|
| `{{<queryFieldName>.<path>}}` | another `query` field on the same type | ✓ per-item forecast |
| `{{route.*}}` | route parameters | ✓ |
| `{{query.*}}` | the URL query string | ✓ `?lat=48.85` reached the remote as `48.85` |
| `{{field.<name>}}` | a value an editor filled in another field of the same block | documented; use it for a block the editor configures |
| `{{now.*}}`, `{{current.locale}}` | the clock, the request | documented |
| `config.defaults` | fallback for anything the request did not supply | ✓ resolved to the default when `?lat=` was absent |

The rule to remember: **`src` is resolved in the same variable context as a `query` field's `where`.** If you can filter a collection on it, you can put it in a URL.

## 2. Read it in the template

The field arrives as the parsed JSON object. No filter, no decoding step.

```liquid
{% if weather.daily %}
  <dl>
    {% for jour in weather.daily.time %}
      {% assign i = forloop.index0 %}
      <div>
        <dt>{{ jour | date: 'ddd D MMM' }}</dt>
        <dd>
          {{ weather.daily.temperature_2m_max[i] | round }}°
          / {{ weather.daily.temperature_2m_min[i] | round }}°
          {% include '_wmoLabel', code: weather.daily.weather_code[i] %}
        </dd>
      </div>
    {% endfor %}
  </dl>
{% else %}
  <p>{{ 'Bulletin momentanément indisponible.' | t }}</p>
{% endif %}
```

Two habits this snippet is built on.

**You do not control the remote's shape, so read it defensively.** Open-Meteo returns *parallel arrays* — `daily.time`, `daily.temperature_2m_max`, `daily.weather_code` — not one object per day. So you loop the dates and index the others at the same rank. Indexing with a variable works (`array[i]`); nothing special is needed.

**Anything the remote controls is untrusted input.** Liquid never auto-escapes. A forecast is numbers, but a partner catalogue is somebody else's free text, and it lands in your page unfiltered. `| escape` it — the same discipline as a form field, for the same reason.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div>The date above is formatted with <code>| date: 'ddd D MMM'</code> and comes out <i>jeu. 20 août</i> on the French page, <i>Thu 20 Aug</i> on the English one. The filter follows the rendered locale on its own — see <a href="/en/docs/multilingual-site">Run a multilingual site</a>.</div></div>

## 3. A dead feed is silent, and that is the whole problem

Point `src` at something unreachable and render the page. Measured on Cairn:

- the page returns **HTTP 200**;
- the field is an **empty object** — not null, not an error, not a log line you will notice;
- nothing anywhere says the fetch failed.

Which means: **without the `{% else %}` branch, a broken feed removes your block and tells no one.** The section heading stays, the data vanishes, and the page keeps passing every check you have. Nobody files a bug about a page that returns 200.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Guard on the data you are about to loop, not on the field.</b> <code>{% if weather %}</code> passes for an empty object and even for an empty string — in Liquid, <code>""</code> is truthy. Test the branch you actually need: <code>{% if weather.daily %}</code>.</div></div>

The fallback text is worth writing properly. "Temporarily unavailable — check with the national weather service before you set out" is a useful sentence on a mountain site. A blank space is not.

Note that this failure mode and a **mistyped variable** produce exactly the same symptom: an empty object, in 200. When a feed you believe in comes back empty, suspect the spelling in `src` before you suspect the remote.

## 4. The timeout is a page-speed budget, not a safety net

Same measurement, with a stopwatch. `timeout: 5`, endpoint unreachable:

```text
code=200  time=5.24s
```

The visitor waited **the full timeout** for a page whose remote data never arrived. That is the number to design against, because it is paid by every request that misses the cache — and it is paid on the *first* request after the cache expires, by whichever visitor happens to be first.

So:

- **Keep `timeout` small.** 3–5 s. The maximum is 60, and 60 seconds of a held connection is not a page, it is an outage.
- **Cache generously.** `validity` is in minutes, default 20. A forecast at 60 is honest; exchange rates at 5 are not worth the latency they cost. Ask what the data's real refresh rate is, then round up.
- **Watch the multiplier.** Keying the feed per item is powerful, and it means one cache entry *per item*. Eight routes is nothing. Eight hundred products, each with its own remote price, is a different conversation — and the answer there is usually a scheduled import into a collection, not a render-time fetch.

## 5. Where the config/content line falls

`src` is **configuration**: one URL per type, identical for every page or block of that type, invisible and uneditable in the back-office.

That is not a limitation once you see where the flexibility actually lives. What varies is what the URL *reads*: a collection field (`{{itinerary.coords.lat}}`), a field the editor filled (`{{field.city}}`), a URL parameter (`{{query.lat}}`). The editor never touches the endpoint — they fill a `geo` field on a route, and the right forecast follows.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>An API key in <code>src</code> is a key in your template repository.</b> The YAML is site settings, not a secret store, and the URL travels to anyone who can read the site's configuration. For a keyed feed, prefer <code>config.headers</code> (<code>Authorization: 'Bearer …'</code>) or <code>config.auth</code> — same exposure, but at least the credential is not sitting in a URL that gets pasted into tickets and screenshots.</div></div>

## 6. Writing back, and picking a neighbour

`api` only reads. The rest of the family:

| Need | Field |
|---|---|
| Read a remote JSON document at render | `api` |
| Send a form payload to an external webhook | `http` — the response is **not** exposed to templates |
| Offer a dropdown fed by a remote list | `smartselect` with `source: api` — hits the remote on **every CMS edit** |
| Read your own collections | `query` — never an `api` field pointed at your own site |

That last row matters more than it looks. A collection on your own site is a `query`: in-process, no timeout, no cache to reason about, no 200-with-empty-object failure mode. Reaching your own data over HTTP buys you every problem on this page and nothing else.

## 7. Serving a feed, rather than consuming one

The mirror image — publishing your own JSON for someone else to consume — is a page-type with a `content-type` header and a template with no layout. It is written up in [Build an agenda](/en/docs/events-agenda), in the section on serving the agenda as a feed.
