Charts in a README
A README is static Markdown, but a chart in it doesn't have to be. Here's a chart that redraws itself every day from fresh numbers, follows GitHub's light and dark themes, and never puts an image file in your repository.
One chart, by hand
For a chart that doesn't change, such as benchmark results or a survey, make it in the chart URL maker and paste its Markdown:

GitHub doesn't load README images from where they live. It fetches them through its image proxy (camo), which keeps a copy. That suits signed URLs: the first fetch draws the chart, the result is cached, and a busy README costs one image rather than one per view. Use the HTML form, <img src="…" width="800" height="300" alt="…">, when you want to set the size or alt text. Leave exp off a README URL, because an expired URL is a broken image.
Why the URL changes when the numbers do
A signed URL is the chart: the numbers are in its query, and the signature covers them. New numbers mean a new URL, and that's what keeps a README current despite every cache between you and the reader. GitHub's proxy, Cloudflare's edge and our own storage all hold a URL's image, but none of them has ever seen tomorrow's URL. So to keep a chart current, regenerate the URL and commit it. A scheduled GitHub Action does that for you.
A chart that updates itself
The example charts a package's weekly npm downloads for the last 12 weeks. Any number you can fetch in a script works the same way: stars, test coverage, a benchmark, a row from your database.
1. Make a signed base, locked to charts, so its secret can only ever sign chart URLs:
curl -X POST https://api.imageapis.com/v1/signed-bases -H 'X-API-Key: YOUR_KEY' \
-H 'Content-Type: application/json' -d '{"name": "README charts", "template": "chart"}'
In the repository, under Settings → Secrets and variables → Actions, add the response's id as IMAGEAPIS_BASE and its secret as IMAGEAPIS_SECRET. The secret lives there and nowhere else. The URLs it signs are safe to publish, because each one draws exactly one chart and can't be edited into another.
2. Mark the spot in README.md where the chart goes:
<!-- chart:start -->
<!-- chart:end -->
3. Add the script as scripts/readme-chart.mjs. It needs Node 18 or later and nothing from npm:
// Weekly npm downloads → two signed chart URLs (light and dark) → the marked section of README.md.
import { createHmac } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
const { NPM_PACKAGE, IMAGEAPIS_BASE, IMAGEAPIS_SECRET } = process.env;
const WEEKS = 12;
// the last 12 full weeks of daily counts, ending yesterday, summed a week at a time
const day = (d) => d.toISOString().slice(0, 10);
const end = new Date(Date.now() - 86400e3);
const start = new Date(end.getTime() - (WEEKS * 7 - 1) * 86400e3);
const res = await fetch(`https://api.npmjs.org/downloads/range/${day(start)}:${day(end)}/${NPM_PACKAGE}`);
if (!res.ok) throw new Error(`npm answered ${res.status}`);
const daily = (await res.json()).downloads;
const weeks = [];
for (let i = 0; i + 7 <= daily.length; i += 7) {
weeks.push({ from: daily[i].day, total: daily.slice(i, i + 7).reduce((n, d) => n + d.downloads, 0) });
}
// millions or thousands, so the numbers on the bars stay short
const big = Math.max(...weeks.map((w) => w.total)) >= 1e6;
const values = weeks.map((w) => (big ? (w.total / 1e6).toFixed(1) : Math.round(w.total / 1e3))).join(",");
const labels = weeks.map((w) => new Date(w.from + "T00:00:00Z").toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })).join(",");
function chartUrl(params) {
const path = `/s/${IMAGEAPIS_BASE}/chart.svg?${new URLSearchParams(params)}`;
const sig = createHmac("sha256", IMAGEAPIS_SECRET).update(path).digest("hex");
return `https://img.imageapis.com${path}&sig=${sig}`;
}
const chart = { kind: "bar", values, labels, unit: big ? "M" : "k", show_values: "true", label_size: 14, width: 800, height: 300, padding: 16, fill: "#6366f1" };
const light = chartUrl({ ...chart, background: "#ffffff", label_color: "#57606a" });
const dark = chartUrl({ ...chart, background: "#0d1117", label_color: "#8b949e", track: "rgba(255,255,255,0.08)" });
// fetch each once: it proves the URL works before the README points at it, and the render is cached for GitHub
for (const url of [light, dark]) {
const r = await fetch(url);
if (!r.ok || !String(r.headers.get("content-type")).startsWith("image/")) throw new Error(`chart answered ${r.status}: ${await r.text()}`);
}
const alt = `Weekly npm downloads of ${NPM_PACKAGE}, last ${WEEKS} weeks`;
const block = `<!-- chart:start -->
<picture>
<source media="(prefers-color-scheme: dark)" srcset="${dark}">
<img src="${light}" width="800" height="300" alt="${alt}">
</picture>
<!-- chart:end -->`;
const readme = await readFile("README.md", "utf8");
if (!/<!-- chart:start -->[\s\S]*?<!-- chart:end -->/.test(readme)) throw new Error("README.md has no <!-- chart:start --> … <!-- chart:end --> markers");
await writeFile("README.md", readme.replace(/<!-- chart:start -->[\s\S]*?<!-- chart:end -->/, () => block));
console.log(`${weeks.length} weeks, last ${weeks.at(-1).total} downloads`);
It fetches each new URL once before writing it. That proves the URL works, so a failed render or a plan out of images fails the run instead of putting a broken image in your README. It also means the chart is already drawn and cached when GitHub first asks for it.
4. Schedule it as .github/workflows/readme-chart.yml:
name: README chart
on:
schedule:
- cron: "17 6 * * *" # every day at 06:17 UTC
workflow_dispatch: # and from the Actions tab, by hand
permissions:
contents: write
jobs:
chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: node scripts/readme-chart.mjs
env:
NPM_PACKAGE: express
IMAGEAPIS_BASE: ${{ secrets.IMAGEAPIS_BASE }}
IMAGEAPIS_SECRET: ${{ secrets.IMAGEAPIS_SECRET }}
- name: Commit the README if the chart changed
run: |
git diff --quiet README.md && exit 0
git config user.name "github-actions[bot]"
git config user.email "41898299+github-actions[bot]@users.noreply.github.com"
git commit -am "README: weekly downloads chart"
git push
Run it once from the Actions tab to fill the markers. After that it runs every morning, and commits only when the numbers moved. Same numbers, same URL, nothing to commit.
Light and dark
GitHub shows a README in the reader's theme, and a chart drawn for white looks pasted-on in dark mode. The script signs two URLs, one per theme, and wraps them in <picture>. GitHub picks the <source> whose prefers-color-scheme matches and falls back to the <img>:
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.imageapis.com/s/sb_…/chart.svg?…&background=%230d1117&label_color=%238b949e&sig=…">
<img src="https://img.imageapis.com/s/sb_…/chart.svg?…&background=%23ffffff&label_color=%2357606a&sig=…" width="800" height="300" alt="…">
</picture>
The dark one uses GitHub's dark background, #0d1117, with muted labels and fainter grid lines (track). An SVG shown as an image can't load web fonts, so a chart's labels usually appear in the reader's own sans-serif. In a README that's what you want: it matches the text around it.
What it costs
Each URL costs one image the first time it's drawn. The daily workflow makes two URLs (light and dark) on days the numbers change, so it costs about 60 images a month. That fits in the free plan's 150. A URL that stays in a README for weeks can be drawn again about once a week. Our stored copy lasts seven days, and if GitHub's proxy asks after that, the chart is drawn anew for one image.
When the chart doesn't show
GitHub shows a broken image when the URL answers with an error. Open the URL in a browser and you'll get the reason as JSON:
bad_signature(403): the URL was edited after signing, or signed with a different secret. Even a re-encoded comma breaks it. Sign the exact string you publish.quota_exceeded(429): the plan behind the base is out of images until its reset. The script's check catches this before the README changes.not_found(404): the base was deleted. Make a new one and update the two Actions secrets.- A 400 names the parameter at fault, such as a
kindmisspelled orvaluesleft empty, and costs nothing.
If the secret ever leaks, delete the base with DELETE /v1/signed-bases/{id}. Every URL built on it stops, and the next run signs with the new one. The signed URLs guide covers the rest, and the charts guide lists every chart property the query can carry.