Page contents
What it is for
Glance already reads your audience statistics from Umami or Plausible. But some numbers are not there, because they live in your own database: the retention of your players, the number of accounts created, the installs of an app, active subscriptions…
A metrics source is a small API you add to your site. It exposes two read-only addresses: a catalogue (the list of your indicators) and series (their values, day by day). Glance connects to it with a token and knows nothing about your database: all the computation stays with you.
Each indicator becomes a “Site metric” card in the site's Dashboards tab, with its curve, its overall figure and its comparison to the selected period, like any other card.
Two sample cards: a rate with its curve, then a counter split by platform.
What you have to provide is deliberately small: two GET routes, JSON, a token. The rest of this page describes the exact contract (version 1), how Glance interprets your responses, examples, and a minimal server you can run in a minute.
Connecting the source in the app
A source is configured per site. Have the base address of your API and a token ready.
- Open the site in Glance, then its Dashboards tab (“Tableaux”).
- At the very bottom, select “Source de métriques” (the same link shows in the empty state, when there is no dashboard yet).
- Enter the API address: the base URL, the part that comes before
catalogueandseries.https://and the trailing slash are optional, Glance adds them. - Enter the token. It is kept in the device keychain, never in the settings.
- Select Test: Glance calls
catalogueand reports “3 indicators available”, or the server's error message (see Errors). - Select Save.
- Back in the Dashboards tab, select “New dashboard” (“Nouveau tableau”), pick the “Site metric” kind (“Métrique du site”), then an indicator from the catalogue (or a family, see Families). The card appears and follows the displayed period.
Good to know. The URL and the token travel with the end-to-end encrypted sync between your devices, but not with the transfer QR code. “Remove the source” also deletes the token and the site's “Metric” cards, which would have nothing left to show.
About the labels. The app's interface is currently in French: the screen is called “Source de métriques”, and its buttons “Tester” (Test), “Enregistrer” (Save) and “Retirer la source” (Remove the source). The steps above give both names.
The contract, version 1
Two routes, relative to the base address entered in the app. Everything is JSON, UTF-8. Field names are French and part of the contract: valeur, libelle, delai_jours…
Authentication
Every request, catalogue and series alike, carries the header Authorization: Bearer <token>. The token is a secret that you choose and that your server compares: Glance does not decode it. Serve the API over HTTPS.
GET https://api.example.com/metrics/catalogue
Authorization: Bearer <your token>
Accept: application/json
GET catalogue
No parameters. Returns the list of indicators Glance can offer.
{
"version": 1,
"indicateurs": [
{
"id": "retention_j1",
"libelle": "Day-1 retention",
"description": "Share of new players back the next day.",
"unite": "pourcentage",
"granularites": ["jour", "mois"],
"delai_jours": 1,
"famille": "retention",
"libelle_famille": "Retention",
"donnees_jusqu_au": "2026-10-18"
}
]
}
| Field | Type | Status | Role |
|---|---|---|---|
version | number | required | Always 1. Any other value is rejected. |
indicateurs | array | required | The indicators, in the order Glance lists them. |
id | text | required | Stable identifier, reused in ids of series. Do not change it: existing cards remember it. |
libelle | text | required | Name shown on the card and in the list. |
description | text | optional | Help sentence shown in the picker. |
unite | text | recommended | pourcentage (values from 0 to 100) or nombre (a count). Missing or unknown: treated as nombre. |
granularites | array of text | optional | Only the value "heure" (hour) is read: it allows hourly points (see Hours). jour (day) and mois (month) are requested according to the displayed period, announced or not. |
delai_jours | integer | optional | Days to wait after a day D before its cohort can be measured (D1: 1, D7: 7). Missing or 0 for a count. See Delays. |
famille | text | optional | Identifier of a group of indicators that differ only by their delay. See Families. |
libelle_famille | text | optional | Name of the family (taken from the first member that has one; otherwise famille). |
donnees_jusqu_au | date | optional | Last day published by the source, YYYY-MM-DD (any other format is ignored). See Late data. |
segments | array | optional | Splits a nombre into parts. Each entry: id (required), libelle (default: the id), estime (boolean, true if the part is only an estimate: “· estimé” is appended to its label). An entry without id is ignored. See Segments. |
GET series
Returns the values of one or more indicators over a date range.
GET https://api.example.com/metrics/series?ids=retention_j1
&de=2026-10-12&a=2026-10-18&granularite=jour&tz=Europe/Paris
Authorization: Bearer <your token>
| Parameter | Value | Role |
|---|---|---|
ids | comma-separated identifiers | Glance asks for one at a time, but your server must accept the list. |
de, a | YYYY-MM-DD | First and last day, inclusive, in the tz time zone. 400 days at most: beyond that, answer 400. |
granularite | jour, mois or heure | The size of a point (day, month, hour). heure is only requested under the conditions described below. Glance never asks for weeks. |
tz | IANA time zone | The device's (Europe/Paris), UTC if it does not provide one. This is the zone in which you cut your days. |
{
"version": 1,
"series": [
{
"id": "retention_j1",
"points": [
{
"t": "2026-10-12",
"valeur": 14.3,
"numerateur": 21,
"denominateur": 147
}
]
}
]
}
| Field | Type | Status | Role |
|---|---|---|---|
version | number | required | Always 1. |
series | array | required | One entry per requested id. An id missing from the response gives a card with no point. |
id | text | required | The series' indicator. |
points | array | required | One point per bucket of the range, empty ones included. The first may start before de (the month containing the first day, for instance). |
t | text | required | Start of the bucket, in local time without a time zone: YYYY-MM-DD (day; for a month, its first day), or YYYY-MM-DDTHH:00 (hour). |
valeur | number or null | required | The measurement. null: not measurable (see Units and gaps). |
numerateur, denominateur | integers | required for a % | The headcounts behind the percentage. They feed the card's overall rate. Missing: read as 0. Ignored for a count. |
segments | object | optional | For a split indicator: the value of each part, { "site": 190, "appli": 230 }. A point without this key is unknown, not zero. |
totaux | object | optional | On the series: the distinct total of each part over the whole requested range. See Segments. |
Errors
Answer with an HTTP status code. Glance translates each one into a message it shows as-is on the card (and under the Test button). The messages are in French in the app; the English equivalent is given here.
| Server response | Message shown in Glance |
|---|---|
404 | “Source non configurée sur le serveur” (source not configured on the server) |
401 (missing or wrong token) | “Jeton refusé” (token rejected) |
400 with {"error":{"code":"…"}} | “Requête refusée (code)” (request rejected); with no readable code: “Requête refusée (inconnu)” |
200 with version ≠ 1 | “Version d'API non prise en charge” (API version not supported) |
| Any other status, or a body that is not a JSON object | “Réponse inattendue du serveur” (unexpected server response) |
| No response (network, timeout: 15 s to connect, 25 s to answer) | “Chargement impossible.” (unable to load) |
{ "error": { "code": "plage_trop_longue" } }
Glance does not retry automatically after one of these errors: the message stays on the card until the next refresh, so you can fix the cause.
How Glance reads your responses
The contract fits in a few fields, but each has a visible consequence. Here are the rules, as the app applies them.
Units, gaps and overall rate
pourcentage:valeurgoes from 0 to 100 (14.3 for 14.3%). Glance divides it by 100 to display it.nombre:valeuris a count. Anullthere counts as 0: a counter is never “not measurable”.valeur: nullon a percentage = not measurable (cohort not finished, or too few people). It is a gap in the curve, never a 0: “nothing measured” is not “0%”. Its headcounts do not count towards the total.- Privacy threshold: 5. Return
nullwhen a bucket's denominator is below 5: a rate over three people means nothing. Glance displays thevaleurit receives and does not recompute it. - Overall rate of the card = Σ numerators ÷ Σ denominators of the measured points, never the average of the rates. It is not displayed if Σ denominators is below 5.
- Buckets you do not send are filled in: a gap for a percentage, 0 for a count. Points outside the displayed window are dropped.
Delays and cohorts (delai_jours)
The day-7 retention of day D is only known on day D + 7. To keep the card aligned with the selected period, Glance places each point at its measurement date, not its cohort date.
- Your
tis the cohort day;valeuris its rate, measured oncedelai_jourshave elapsed. - For a period from October 15 to 21, a D7 indicator receives
de=2026-10-08&a=2026-10-14: the cohorts of the 8th to 14th, displayed at the dates of the 15th to 21st. - A point whose measurement day is not over (today) stays a gap, whatever the server answers. On “Today”, a D1 therefore has nothing measurable, and the card says “Pas encore mesurable sur cette période” (not yet measurable on this period).
- In months, Glance asks for months and shifts each bucket by
delai_joursbefore attaching it to its month. Aggregate the whole month on the server: every single day often falls under the threshold of 5.
Families (famille)
Several indicators that differ only by their delay (D1, D7, D30) form a family. In the editor, the user picks the family once; Glance picks the member at each computation: the largest delay that fits four times in the displayed window, otherwise the shortest.
| Period | Length | Member chosen |
|---|---|---|
| Today, 24 h, 7 days | 1 to 7 days | D1 (D7 would need 28 days) |
| 30 days | 30 days | D7 (28 ≤ 30; D30 would need 120) |
| 12 months | 365 days | D30 (120 ≤ 365) |
A single card thus follows the period, instead of three cards of which two are empty. It shows the family name and “J7 · s'ajuste à la période” (adjusts to the period). A sample catalogue is further down, in Examples.
The members of a family must share the same unite (Glance uses the one of the shortest delay), and libelle_famille is enough on just one of them.
Late data (donnees_jusqu_au)
Some sources publish with a delay (an app store's reports, for example). Announce the last published day, YYYY-MM-DD:
- The buckets after it are gaps, even for a
nombre: a day not yet published is not a day at 0. They are left out of the totals. - The card notes “Données jusqu'au 18 oct.” (data up to Oct 18). If the whole window comes after, it says “Pas encore publiées” (not yet published).
- The date applies to the series'
t. With a delay, Glance shifts it bydelai_jours, like the points. - On the server, it is advisable to return, for the month bucket that contains this date, its partial sum, and
nullonly for a bucket that starts after it.
Segments (segments and totaux)
A nombre can be split into parts (website, app, other platform…). Declare them in the catalogue, then give each part's value in every point.
- The card draws a “Total” curve plus one curve per part (clickable legend), then one bar per part over the period.
- A point without a
segmentskey is unknown, not zero: the source could not split that day yet. Each part is a gap there;valeurremains the true distinct total. totauxgives the number of distinct people over the whole range for each part. The bars are computed from it, never from the sum of the points: a player active on two days would count twice.- The sum of the parts may exceed the total, if one person goes through two platforms: the bar is therefore read as a share of Σ
totaux. - As long as no part has a value, the card stays a plain counter. Segments do not apply to a
pourcentage.
Hours (granularites)
By default, even on “Today”, Glance asks for days and the card shows one point per day. It asks for granularite=heure only if all three conditions are met:
- the displayed period reads in hours (“Today”, “24 h”…);
- the indicator announces
"heure"ingranularites; - it has no delay (
delai_joursmissing or 0).
Then: de and a remain days (the source returns whole days, Glance keeps only the hours of the window); t is YYYY-MM-DDTHH:00 in local time, one point per hour, empty ones included (23 or 25 points on daylight-saving days); 30 days at most, beyond that answer 400 plage_trop_longue. The totaux cover the whole days.
Limits and good practice
- 400 days at most per request. For “All time” or a longer custom period, Glance itself brings
deback toa− 399 days: the earlier buckets stay empty (a gap for a %, 0 for a count). - Version: any
versionother than1is rejected. An incompatible change to the contract will come as a new version, not a silent change. - Cache: cache responses on the server, 5 minutes is fine. Several cards and several devices query the same source.
- Read-only, no personal data: expose aggregates only. The threshold of 5 exists so that no figure points at a person.
- Token: compare it in constant time, revoke it if a device is lost, and never put it in a URL.
Example responses
A percentage, with gaps
Day-1 retention over seven days. On the 14th, the cohort has only 3 people: null. On the 18th, the cohort is not measurable yet: null too. The overall rate is (21 + 17 + 23 + 18 + 15) ÷ (147 + 140 + 146 + 138 + 126), that is 94 ÷ 697 ≈ 13.5%, leaving out the two gaps.
{
"version": 1,
"series": [{
"id": "retention_j1",
"points": [
{ "t": "2026-10-12", "valeur": 14.3, "numerateur": 21, "denominateur": 147 },
{ "t": "2026-10-13", "valeur": 12.1, "numerateur": 17, "denominateur": 140 },
{ "t": "2026-10-14", "valeur": null, "numerateur": 2, "denominateur": 3 },
{ "t": "2026-10-15", "valeur": 15.8, "numerateur": 23, "denominateur": 146 },
{ "t": "2026-10-16", "valeur": 13.0, "numerateur": 18, "denominateur": 138 },
{ "t": "2026-10-17", "valeur": 11.9, "numerateur": 15, "denominateur": 126 },
{ "t": "2026-10-18", "valeur": null, "numerateur": 0, "denominateur": 0 }
]
}]
}
A count, with parts
An indicator split into three platforms, declared like this in the catalogue:
{
"id": "joueurs_actifs",
"libelle": "Active players",
"unite": "nombre",
"granularites": ["heure", "jour", "mois"],
"segments": [
{ "id": "site", "libelle": "Website" },
{ "id": "appli", "libelle": "App" },
{ "id": "tiers", "libelle": "Others", "estime": true }
]
}
Its series: the 18th has no segments key (the day is not split yet), and totaux gives the distinct people of the range. The “Website” share is 310 ÷ (310 + 402 + 9) ≈ 43%.
{
"version": 1,
"series": [{
"id": "joueurs_actifs",
"points": [
{ "t": "2026-10-16", "valeur": 412,
"segments": { "site": 190, "appli": 230, "tiers": 5 } },
{ "t": "2026-10-17", "valeur": 388,
"segments": { "site": 171, "appli": 221, "tiers": 4 } },
{ "t": "2026-10-18", "valeur": 120 }
],
"totaux": { "site": 310, "appli": 402, "tiers": 9 }
}]
}
A D1 / D7 / D30 family
Three indicators of the same family. In the editor, a single “Retention” entry shows up.
{
"version": 1,
"indicateurs": [
{ "id": "retention_j1", "libelle": "Day-1 retention", "unite": "pourcentage",
"granularites": ["jour", "mois"], "delai_jours": 1,
"famille": "retention", "libelle_famille": "Retention" },
{ "id": "retention_j7", "libelle": "Day-7 retention", "unite": "pourcentage",
"granularites": ["jour", "mois"], "delai_jours": 7,
"famille": "retention", "libelle_famille": "Retention" },
{ "id": "retention_j30", "libelle": "Day-30 retention", "unite": "pourcentage",
"granularites": ["jour", "mois"], "delai_jours": 30,
"famille": "retention", "libelle_famille": "Retention" }
]
}
Hourly points
Response to granularite=heure for an indicator with no delay that announces "heure":
{
"version": 1,
"series": [{
"id": "joueurs_actifs",
"points": [
{ "t": "2026-10-18T00:00", "valeur": 12 },
{ "t": "2026-10-18T01:00", "valeur": 7 },
{ "t": "2026-10-18T02:00", "valeur": 3 }
]
}]
}
A minimal example server
About sixty lines of Node.js, no dependency: the native http module is enough. It serves a catalogue of two indicators (comptes_crees, a count, and retention_j1, a percentage with a delay), checks the token, validates the parameters, handles days and months, applies the threshold of 5 and the wait for the cohort. The figures are made up: replace dayStats with your queries.
const http = require('node:http');
const TOKEN = process.env.METRICS_TOKEN || 'change-me';
const DAY = 86_400_000;
const MAX_DAYS = 400;
const MIN_DEN = 5; // privacy threshold: below it, valeur = null
const CATALOGUE = { version: 1, indicateurs: [
{ id: 'comptes_crees', libelle: 'Accounts created', description: 'Sign-ups of the day.',
unite: 'nombre', granularites: ['jour', 'mois'] },
{ id: 'retention_j1', libelle: 'Day-1 retention', description: 'New users back the next day.',
unite: 'pourcentage', granularites: ['jour', 'mois'], delai_jours: 1 },
] };
const DELAI = { comptes_crees: 0, retention_j1: 1 };
// Replace with your queries: one day -> { n, d } (numerator, denominator).
const dayStats = (id, day) => {
const k = Date.parse(day) / DAY;
return id === 'comptes_crees' ? { n: 20 + (k % 7) * 3, d: 0 } : { n: 10 + (k % 5), d: 90 + (k % 9) };
};
function series(q) {
const ids = (q.get('ids') || '').split(',').filter(Boolean);
const de = Date.parse(q.get('de')), a = Date.parse(q.get('a'));
const gran = q.get('granularite') || 'jour';
if (!ids.length || ids.some((id) => !(id in DELAI))) return [400, 'indicateur_inconnu'];
if (isNaN(de) || isNaN(a) || a < de) return [400, 'dates_invalides'];
if ((a - de) / DAY + 1 > MAX_DAYS) return [400, 'plage_trop_longue'];
if (gran !== 'jour' && gran !== 'mois') return [400, 'granularite_inconnue'];
const today = Date.now() - (Date.now() % DAY);
return [200, { version: 1, series: ids.map((id) => {
const buckets = new Map();
for (let t = de; t <= a; t += DAY) {
const day = new Date(t).toISOString().slice(0, 10);
const key = gran === 'mois' ? day.slice(0, 8) + '01' : day;
const b = buckets.get(key) || { n: 0, d: 0, done: true };
const s = dayStats(id, day);
buckets.set(key, { n: b.n + s.n, d: b.d + s.d, done: b.done && t + DELAI[id] * DAY < today });
}
return { id, points: [...buckets].map(([t, b]) => ({
t,
valeur: id === 'comptes_crees' ? b.n
: b.done && b.d >= MIN_DEN ? Math.round((1000 * b.n) / b.d) / 10 : null,
numerateur: b.n, denominateur: b.d,
})) };
}) }];
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const send = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(status === 400 ? { error: { code: body } } : body));
};
if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, {});
if (url.pathname === '/metrics/catalogue') return send(200, CATALOGUE);
if (url.pathname === '/metrics/series') return send(...series(url.searchParams));
send(404, {});
}).listen(3000, () => console.log('http://localhost:3000/metrics/'));
Start it with METRICS_TOKEN=my-token node server.js, and try it with curl:
curl -H 'Authorization: Bearer my-token' \
http://localhost:3000/metrics/catalogue
curl -H 'Authorization: Bearer my-token' \
'http://localhost:3000/metrics/series?ids=retention_j1&de=2026-10-12&a=2026-10-18&granularite=jour'
Then, in Glance, enter the address http://<your-machine-ip>:3000/metrics/ and the token. In production, put it behind HTTPS (a reverse proxy is enough): the token travels in every request.
This is only a skeleton. It handles neither heure, nor delai_jours beyond the cohort wait, nor segments, nor caching. Add what your indicators need by following the previous sections.