API reference

One endpoint, /chart. Send a Chart.js config as JSON and get a PNG back. The OpenAPI 3.1 spec is at /openapi.json.

Quick start

Put the URL in an <img>, an email, a Markdown file or a Slack message. No key is needed. Free images carry a small watermark.

<img src="https://chartpng.dev/chart?c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%22Mon%22%2C%22Tue%22%2C%22Wed%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Signups%22%2C%22data%22%3A%5B12%2C19%2C7%5D%7D%5D%7D%7D&w=600&h=360" alt="Signups by day" />

The config never runs as code, so functions in it are not supported. Send JSON in Chart.js 4 syntax; the common options of a Chart.js v2 config, the kind QuickChart uses by default, are translated. The typical slips of language models are repaired for you (see lenient parsing). A GET accepts QuickChart's parameter names chart, width, height, backgroundColor and devicePixelRatio. QuickChart's other parameters, such as version, format and key, are refused with a 400.

GET /chart

All parameters go in the query string. Unknown or repeated parameters are refused with a 400, so a URL cannot be varied to bypass the cache. A QuickChart name and its short name count as the same parameter.

ParameterQuickChart nameTypeDefaultNotes
c (required)chartstringnoneChart.js configuration as URL-encoded JSON, at most 16 KB, checked after any repair. Typical near-JSON from language models is repaired and the repairs are listed in the X-ChartPNG-Fixes response header; functions written as text are removed, never run. type is one of bar, line, pie, doughnut, radar, polarArea, scatter, bubble. Report presets are set in options.plugins.chartpng.preset: kpi, ranking, comparison, funnel or gauge. Their extra options sit beside it: previous (number, kpi only), max (number above 0, gauge only, default 100), prefix and suffix (strings around the kpi number, the ranking, funnel and gauge values; comparison does not use them), values (false hides the numbers a preset draws). funnel needs type bar and one dataset with a value per stage; gauge needs type doughnut and one dataset whose first number is the value. Anything the config sets wins over the preset. options.plugins.chartpng.theme is false, default, editorial, mono, colorblind, vivid or ocean; any other name is a 400. options.plugins.chartpng.palette is a list of 1 to 12 hex colours that replaces the theme's series colours. options.plugins.chartpng.preset also takes the output presets email and slack, alone or with a report preset as 'ranking,email'. The query parameters theme, palette and preset are the same settings; the config wins when both set one. Any other preset value is a 400 whose error message lists the valid presets.
wwidthinteger, 50 to 2000600Width in pixels.
hheightinteger, 50 to 2000360Height in pixels.
bkgbackgroundColorstring#ffffffBackground colour, any CSS colour, at most 32 characters. A longer value falls back to white.
dprdevicePixelRatio1 or 21Device pixel ratio. 2 gives twice the pixels for retina screens. The free plan renders up to 4 megapixels (w x h x dpr squared).
knonestringnoneKey id for a signed URL: the first 12 hex characters of the SHA-256 of the key. Send it with s.
snonestringnoneSignature for a signed URL: the first 32 hex characters of an HMAC-SHA256 over [c, w, h, bkg, dpr] as they appear in the URL, with [theme, palette, preset] added after them when the URL has any of the three. Changing any of them invalidates it. Send it with k.
themenonedefault or editorial or mono or colorblind or vivid or ocean or falsedefaultNamed theme: default, editorial, mono, colorblind, vivid or ocean; false turns the ChartPNG look off. The same as options.plugins.chartpng.theme, which wins when the config sets it. Light or dark colours follow bkg. Any other name is a 400.
palettenonestringnoneSeries colours, comma-separated hex: 1f5fad,e0712c,13896f. The # is optional (in a URL it must be written %23). 1 to 12 colours, #rgb or #rrggbb; anything else is a 400. Replaces the theme's colours for datasets that set none. The same as options.plugins.chartpng.palette, which wins when the config sets it.
presetnonestringnoneReport preset (kpi, ranking, comparison) and/or output preset (email, slack), at most one of each, comma-separated: ranking,email. An output preset sets w, h, dpr and bkg when the request leaves them out (email 600 x 360 at dpr 2, slack 800 x 400 at dpr 2; a transparent bkg becomes white), gives the image roomier margins and keeps all type at 11 px or more. The same as options.plugins.chartpng.preset; for each of the two kinds the config wins when it sets one.

With a GET, k and s make a signed URL for a Pro key. See signed URLs.

POST /chart

The same fields in a JSON body of at most 20 KB. Use it for configs too long for a URL and for server-side calls with a Pro key.

A missing or unknown key is not an error. The chart is drawn with the watermark, as on the free plan.

POST /chart.json

The same body as POST /chart, but nothing is drawn. It answers with the public URL of the free plan and the text and snippets to paste the image in a page, an email or Slack. No key is needed.

curl https://chartpng.dev/chart.json \
  -H "Content-Type: application/json" \
  -d '{"c":{"type":"bar","data":{"labels":["Mon","Tue","Wed"],"datasets":[{"label":"Signups","data":[12,19,7]}]}},"w":600,"h":360}'
{ "url": "https://chartpng.dev/chart?c=...&w=600&h=360", "alt": "Bar chart: ...", "markdown": "![...](...)", "html": "<img src="..." alt="..." width="600" height="360">", "fixes": [] }

Short URLs

POST /c takes the same body as POST /chart, keeps the chart for 90 days and answers with a short image URL, https://chartpng.dev/c/{id}.png. The URL stays short however large the config is, so it fits Slack's 3000-character image_url, emails and Markdown.

curl https://chartpng.dev/c \
  -H "Content-Type: application/json" \
  -d '{"c":{"type":"bar","data":{"labels":["Mon","Tue","Wed"],"datasets":[{"label":"Signups","data":[12,19,7]}]}},"w":600,"h":360}'
{ "id": "Ab3dE5gH7jK", "url": "https://chartpng.dev/c/Ab3dE5gH7jK.png", "expires": "2027-02-01T09:00:00.000Z", "alt": "Bar chart: ...", "markdown": "![...](...)", "html": "<img src="..." ...>" }

Signed URLs

Chart images end up in emails and on public pages, and anyone who sees the page sees the URL. So the key never goes in a URL. A signed URL carries two values instead: k, an id derived from the key by a hash, and s, an HMAC-SHA256 ofc, w, h, bkg and dpr made with the key. The server checks it and draws without the watermark.

import { chartUrl } from '@chartpng/client';

const config = {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed'],
    datasets: [{ label: 'Signups', data: [12, 19, 7] }],
  },
};

// Runs on your server. The URL carries k and s, never the key.
const url = chartUrl(config, {
  apiKey: process.env.CHARTPNG_API_KEY,
  width: 600,
  height: 360,
  dpr: 2,
});

Limits

WhatLimit
Chart config16 KB, in GET and in POST
POST body20 KB. Larger bodies get a 413.
Width and height50 to 2000 px, whole numbers
dpr1 or 2
Free plan size4 megapixels: w × h × dpr². For example 2000 × 2000 at dpr 1.
Pro sizeUp to 2000 × 2000 at dpr 2
ticks.count, ticks.maxTicksLimitAt most 100
NestingAt most 16 levels
ParametersOnly those in the table. Unknown or repeated ones get a 400. This applies to GET.
Short URLsKept 90 days from the last POST /c of the same chart. 200 a month per address on the free plan, 5,000 per Pro key.
Backgroundbkg up to 32 characters. A longer value is ignored and the background is white.

Errors

Every error is JSON, {"error": "..."}, with CORS headers so browsers can read it. Nothing is drawn when a request is refused.

StatusMessageWhen
400unknown parameter: xA GET query has a name that is not in the table above.
400parameter given twice: cThe same parameter, or a parameter and its alias, appears twice.
400missing chart config (c)No c, or a POST body that is not a JSON object.
400chart config must be valid JSONc is a string that does not parse, even after the repairs under lenient parsing.
400chart config is larger than 16 KBc is over the limit, as a string or as an object.
400type must be one of: bar, line, pie, doughnut, radar, polarArea, scatter, bubbleAny other chart type.
400chart config needs a data objectThe config has no data.
400chart config is nested deeper than 16 levelsObjects inside objects past 16 levels.
400ticks.count must be at most 100Also for ticks.maxTicksLimit.
400options.scales.x.ticks must be an objectChart.js failed to draw because a value is not an object where it needs one: options.plugins.legend, or ticks, grid, border or title of a scale. The message names the path. A hidden axis that never reads the value still draws.
400width and height must be integers between 50 and 2000w or h is out of range or not a whole number.
400dpr must be 1 or 2Any other pixel ratio.
400the free plan renders up to 4 megapixels (w × h × dpr²); a Pro key lifts itFree image over 4 megapixels.
400unknown preset "x"; valid presets: kpi, ranking, comparison, funnel, gauge, email, slackpreset, or options.plugins.chartpng.preset, has a name that is not one of the seven.
400preset takes at most one of email, slackTwo output presets, or two report presets (kpi, ranking, comparison, funnel, gauge), in one preset value. One of each kind is fine: ranking,email.
400unknown theme "x"; valid themes: default, editorial, mono, colorblind, vivid, oceantheme, or options.plugins.chartpng.theme, is not false and not one of the six names.
400palette must be 1 to 12 hex colours such as 1f5fad,e0712c (#rgb or #rrggbb); got "x"palette, or options.plugins.chartpng.palette, has a colour that is not hex, an empty item, or fewer than 1 or more than 12 colours.
400preset kpi needs type line or barThe config does not fit its preset. The message names the preset and what is wrong, such as the type or the number of datasets.
413request body is larger than 20 KBA POST body over the limit. Checked before parsing.
400numbers in the chart are too large to drawA value or axis end above 1e300. Chart.js cannot step an axis that wide.
400could not draw this chart with these optionsChart.js threw while drawing, usually an option of the wrong shape (an array where it expects an object).
500could not render this chartSomething failed on our side. The details stay on the server.
429this address has made its 200 short URLs this month; ...POST /c past the monthly cap of short URLs: 200 per address on the free plan, 5,000 per Pro key.
429free short URLs are used up for today; ...POST /c without a key once the day's storage for free short URLs is used up. It frees again the next day (UTC).
503short URLs are unavailable right now; ...POST /c or GET /c/{id}.png while storage cannot be reached. /chart keeps working.

A report preset that is unknown, or that does not fit the config, is also a 400. The body carries only the error message; bad_preset (and bad_option for the shape errors above) is the internal reason these errors are counted under, not a field you can read. See report presets.

Theme

A config that does not style itself gets the ChartPNG look. Anything your config sets wins over it.

Turn the whole theme off with options.plugins.chartpng.theme set to false (or the parameter theme=false). Chart.js then draws your config as it is.

{
  "type": "bar",
  "data": { "labels": ["Mon", "Tue"], "datasets": [{ "data": [12, 19] }] },
  "options": { "plugins": { "chartpng": { "theme": false } } }
}

Keep the theme and drop only the numbers drawn on the chart with values set to false:

"options": { "plugins": { "chartpng": { "values": false } } }

Named themes

Pick one of six palettes with the theme parameter, or with options.plugins.chartpng.theme in the config. They are the same setting: when both are set, the config wins. Light or dark is chosen from bkg as in the theme above, and colours your datasets set still win. An unknown name is a 400. false still turns the theme off.

https://chartpng.dev/chart?c={...}&theme=ocean
{ options: { plugins: { chartpng: { theme: 'editorial' } } } }

Every theme colour is checked in the test suite against two light backgrounds (white and paper) and two dark ones (near-black and black), with the WCAG contrast formula: text is at least 4.5:1. Series colours of vivid, ocean and editorial, and the dark variants of mono and colorblind, are at least 3:1. The default palette and the light variants of mono and colorblind keep a few colours below that (the Okabe-Ito yellow, for one), listed in that test. A custom palette is not checked: contrast is yours to judge.

default

The look above. Same as no theme at all.

The same bar chart in the default theme on a light backgroundThe same bar chart in the default theme on a dark background
editorial

Blue, rust and grey, with lighter tones on dark backgrounds.

The same bar chart in the editorial theme on a light backgroundThe same bar chart in the editorial theme on a dark background
mono

Ink, grey and paper tones. On dark backgrounds the greys run from paper to mid grey.

The same bar chart in the mono theme on a light backgroundThe same bar chart in the mono theme on a dark background
colorblind

The Okabe-Ito palette, chosen to stay apart for colour-blind readers. Its black becomes light ink on dark backgrounds.

The same bar chart in the colorblind theme on a light backgroundThe same bar chart in the colorblind theme on a dark background
vivid

Saturated blue, orange, green and purple for dashboards and slides. Lighter tones on dark backgrounds.

The same bar chart in the vivid theme on a light backgroundThe same bar chart in the vivid theme on a dark background
ocean

Blues and teal with a coral accent, then olive, violet and ochre. Lighter tones on dark backgrounds.

The same bar chart in the ocean theme on a light backgroundThe same bar chart in the ocean theme on a dark background

Palette

Your own series colours: palette as a parameter, or options.plugins.chartpng.palette in the config as a list or a comma-separated string. It replaces the theme's colours for datasets that set none, on light and dark backgrounds alike; the rest of the theme stays. When both are set, the config wins.

https://chartpng.dev/chart?c={...}&palette=1f5fad,e0712c,13896f
https://chartpng.dev/chart?c={...}&palette=%231f5fad,%23e0712c
{ options: { plugins: { chartpng: { palette: ['#1f5fad', '#e0712c', '#13896f'] } } } }

Report presets

A preset turns a plain config into a report image: a KPI, a ranking, a comparison, a funnel or a gauge. Set it under options.plugins.chartpng. No new chart types: a preset is a ready-made set of Chart.js options on top of the theme. Anything your config sets wins over the preset.

PresetWhat it draws
kpiA big number with the last value of the first dataset, a change chip against previous (or against the first value when previous is not set), and a sparkline without axes.
rankingHorizontal bars ordered from highest to lowest, the first one highlighted.
comparisonTwo datasets: the first is the current period and the second the previous one, in grey, with the percentage change shown on the current period.
funnelType bar, one dataset with a value per stage (up to 12, none negative). One centred bar per stage from top to bottom, as wide as its value, with the value and the share of the previous stage beside it.
gaugeType doughnut, one dataset whose first number is the value. A half ring filled up to max, with the value big in the middle and the share of max under it.
OptionTypeNotes
presetstringkpi, ranking, comparison, funnel or gauge; also email or slack, see output presets. The preset parameter is the same setting. Any other value is a 400, see the errors table.
previousnumberOnly for kpi. The value the change chip compares with.
maxnumberOnly for gauge. A number above 0: the value at which the ring is full, such as a goal. Default 100. Anything else is a 400.
valuesbooleanfalse hides the numbers the preset draws (ranking values, comparison changes, funnel values, gauge number).
prefixstringkpi: put before the big number. ranking, funnel: put before each value. gauge: before the value and the goal. Such as "$". Not used by comparison.
suffixstringkpi: put after the big number. ranking, funnel: put after each value. gauge: after the value and the goal. Such as "%". Not used by comparison.
{
  "type": "line",
  "data": { "labels": ["W1", "W2", "W3"], "datasets": [{ "label": "MRR", "data": [8200, 9100, 9840] }] },
  "options": { "plugins": { "chartpng": { "preset": "kpi", "previous": 9100, "prefix": "$" } } }
}

One guide for each preset, with a config, a GET URL and a curl example: KPI with a sparkline, ranking chart comparison with the previous period, funnel chart and gauge chart. An n8n workflow that posts them to Slack is in the n8n guide, and the Slack message itself in Charts in Slack.

Output presets

A report preset says what the chart is; an output preset says where the image is going. They share the name preset and no values, so one request can have one of each, separated by a comma: preset=ranking,email. Use the preset parameter, or options.plugins.chartpng.preset in the config as "email" or "ranking,email". For each kind, the config wins when both set one.

PresetImageFor
email600 × 360 at dpr 2: a 1200 × 720 pixel PNG, to show at 600 pixels wide.An <img width="600"> in an email: sharp on retina screens. The email guide explains the sizing.
slack800 × 400 at dpr 2: a 1600 × 800 pixel PNG.A Slack image block, with the sizes of the Slack guide. Slack documents no pixel size for the block: this is our convention, not a limit. The 3000 character limit on image_url still applies.
https://chartpng.dev/chart?c={...}&preset=email
https://chartpng.dev/chart?c={...}&preset=ranking,slack&w=640
{ options: { plugins: { chartpng: { preset: 'ranking,email' } } } }
<img src="https://chartpng.dev/chart?c={...}&preset=email" width="600" alt="Signups by day" style="max-width: 100%; height: auto;" />

Lenient parsing

Models write almost-JSON. ChartPNG repairs the usual slips before it reads the config, and tells you what it changed in theX-ChartPNG-Fixes response header: the codes of the repairs applied, separated by commas. When nothing was repaired the header is absent. If a config cannot be repaired, the request is refused with the same clear error as always.

CodeWhat was repaired
code_fenceThe config came wrapped in a Markdown code fence. The fence is removed.
trailing_commaA comma before a closing bracket or brace. It is removed.
single_quotesStrings in single quotes become double-quoted strings.
unquoted_keysObject keys without quotes get quotes.
double_encodedThe config was JSON inside a JSON string. It is decoded once.
function_stringA function written as text is removed.
v2_scalesA Chart.js v2 axis, scales.xAxes[0] or scales.yAxes[0], became scales.x or scales.y: gridLines is now grid (and gridLines.drawBorder is border.display), scaleLabel.labelString is title.text, ticks.beginAtZero, min, max, suggestedMin, suggestedMax and reverse sit on the scale, and fontColor, fontSize, fontStyle and fontFamily of the ticks and the title become color and font. A list of several axes becomes one entry per id, when every axis has an id and every dataset names one of them. An axis already keyed x or y that still holds gridLines or scaleLabel gets them renamed. Only on bar, line, scatter and bubble charts, when no dataset names a single axis by id and the v4 key is not there yet.
v2_pluginsoptions.legend, options.title or options.tooltips moved to options.plugins.legend, title or tooltip when written as an object, unless that plugin was already set; the font options of the legend labels and the title become color and font. A bare legend: false is left as it came.
v2_datasetsThe dataset option steppedLine became stepped, with the same values.
v2_defaultsA Chart.js v2 line chart whose datasets do not say fill got options.elements.line.fill: true, because Chart.js 2 filled the area under a line by default and Chart.js 4 does not. Only when another v2 option was translated in the same config, so a v4 config never gets it, and a fill on a dataset or in elements.line wins. A chart that sets no colors keeps the ChartPNG theme's own fill.
missing_typeThe config had no type but had datasets, so it was drawn as bar. This one is a guess: set type if you meant another chart.
curl -si -G https://chartpng.dev/chart \
  --data-urlencode "c={type:'bar',data:{labels:['Mon','Tue'],datasets:[{data:[12,19],}]}}" \
  | grep -i '^x-chartpng-fixes'

# x-chartpng-fixes: trailing_comma,single_quotes,unquoted_keys

Caching

Examples

curl

GET, free plan:

curl -G https://chartpng.dev/chart \
  --data-urlencode 'c={"type":"bar","data":{"labels":["Mon","Tue","Wed"],"datasets":[{"label":"Signups","data":[12,19,7]}]}}' \
  -d w=600 -d h=360 -d dpr=2 \
  -o chart.png

POST with a Pro key:

curl https://chartpng.dev/chart \
  -H "Authorization: Bearer $CHARTPNG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"c":{"type":"bar","data":{"labels":["Mon","Tue","Wed"],"datasets":[{"label":"Signups","data":[12,19,7]}]}},"w":600,"h":360,"dpr":2}' \
  -o chart.png

Node

With fetch (Node 18 or later; the @chartpng/client package needs 22):

import { writeFile } from 'node:fs/promises';

const config = {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed'],
    datasets: [{ label: 'Signups', data: [12, 19, 7] }],
  },
};

const res = await fetch('https://chartpng.dev/chart', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: 'Bearer ' + process.env.CHARTPNG_API_KEY,
  },
  body: JSON.stringify({ c: config, w: 600, h: 360, dpr: 2 }),
});
if (!res.ok) throw new Error((await res.json()).error);
await writeFile('chart.png', Buffer.from(await res.arrayBuffer()));

With the @chartpng/client package. chartUrl is under signed URLs; renderChart returns the PNG bytes:

import { writeFile } from 'node:fs/promises';
import { renderChart } from '@chartpng/client';

const config = {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed'],
    datasets: [{ label: 'Signups', data: [12, 19, 7] }],
  },
};

// Same call as the fetch above. Throws "ChartPNG 400: ..." on an error.
const png = await renderChart(config, { apiKey: process.env.CHARTPNG_API_KEY, width: 600, height: 360, dpr: 2 });
await writeFile('chart.png', png);

Python

import os
import requests

config = {
    "type": "bar",
    "data": {
        "labels": ["Mon", "Tue", "Wed"],
        "datasets": [{"label": "Signups", "data": [12, 19, 7]}],
    },
}

res = requests.post(
    "https://chartpng.dev/chart",
    headers={"Authorization": f"Bearer {os.environ['CHARTPNG_API_KEY']}"},
    json={"c": config, "w": 600, "h": 360, "dpr": 2},
    timeout=20,
)
if not res.ok:
    raise RuntimeError(res.json()["error"])
with open("chart.png", "wb") as f:
    f.write(res.content)

MCP server

The @chartpng/mcp server lets an AI agent draw charts. It needs Node 22 or later. It has two tools: chart_url returns a public PNG URL, signed if you set a key, and render_chart returns the image.

Claude Code:

claude mcp add -s user -e CHARTPNG_API_KEY=your-key chartpng -- npx -y @chartpng/mcp@0.2.0

Claude Desktop, Cursor and other clients, in their mcpServers config:

{
  "mcpServers": {
    "chartpng": {
      "command": "npx",
      "args": ["-y", "@chartpng/mcp@0.2.0"],
      "env": { "CHARTPNG_API_KEY": "optional, removes the watermark" }
    }
  }
}

Keep the key in your user-level config, not in a project file that gets committed. Without a key it works and images carry the watermark. CHARTPNG_BASE_URL changes the server, and defaults to https://chartpng.dev.