Consume an external feed
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.
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.
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:
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.
{{itinerary[0].coords.lat}} — the indexed form the template uses — does not resolve. Neither does {{page.custom.itinerary[0].coords.lat}}. Neither does {{field.itinerary.coords.lat}}: field. is for values an editor typed 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: {{itinerary.coords.lat}}.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.
{% 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.
| date: 'ddd D MMM' and comes out jeu. 20 août on the French page, Thu 20 Aug on the English one. The filter follows the rendered locale on its own — see Run a multilingual site.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.
{% if weather %} passes for an empty object and even for an empty string — in Liquid, "" is truthy. Test the branch you actually need: {% if weather.daily %}.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:
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
timeoutsmall. 3–5 s. The maximum is 60, and 60 seconds of a held connection is not a page, it is an outage. - Cache generously.
validityis 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.
src is a key in your template repository. 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 config.headers (Authorization: 'Bearer …') or config.auth — same exposure, but at least the credential is not sitting in a URL that gets pasted into tickets and screenshots.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, in the section on serving the agenda as a feed.
Last updated September 6, 2026.