Render trustworthy charts from JSON.
ChartRelay accepts one small chart specification and returns raw SVG, durable SVG and PNG URLs, accessible text, and an explainable QA report.
Quickstart
Two requests: create an account to get a key, then render a chart with it. Both target https://chartrelay.dev, the origin serving this page. Never expose a production key in browser code.
1. Create an account
Sign up in the browser at /register, or post the same fields. All three are required — name as well as email and password. A body missing any of them is refused with 400, and the error names the field that is missing.
curl -X POST https://chartrelay.dev/api/auth/register \
-H "Content-Type: application/json" \
--data '{
"name": "Ada Lovelace",
"email": "ada@example.com",
"password": "a long enough password"
}'| Field | Type | Rules |
|---|---|---|
name | string | Required. 2–80 characters after trimming. Names your workspace. |
email | string | Required. A valid address, lower-cased on the way in. One account per address. |
password | string | Required. 10–128 characters. |
source | string | Optional attribution slug from a ?ref= link. Anything that is not a short lowercase slug is dropped rather than rejected. |
A successful registration returns 200 with your API key in full. This response is the only copy of it:
{
"ok": true,
"apiKey": "cr_live_...",
"apiKeyNotice": "This is the only time this API key is shown. ChartRelay stores only a hash of it. If you lose it, create another key from your dashboard — your existing keys keep working until you revoke them."
}2. Render a chart
Export the key as CHARTRELAY_API_KEY — the same variable the dashboard quickstart and the MCP server use — and send it as a Bearer token.
curl -X POST https://chartrelay.dev/api/v1/charts \
-H "Authorization: Bearer $CHARTRELAY_API_KEY" \
-H "Content-Type: application/json" \
--data '{
"type": "bar",
"title": "Monthly recurring revenue",
"subtitle": "Revenue grew 84% over six months",
"width": 900,
"height": 520,
"theme": "light",
"showLegend": true,
"showValues": true,
"series": [{
"name": "MRR",
"color": "#6C5CE7",
"data": [
{ "label": "August", "value": 31200 },
{ "label": "September", "value": 33900 }
]
}]
}'Chart schema
| Field | Type | Rules |
|---|---|---|
type | string | bar, line, area, pie, or scatter. Every type uses a categorical axis; scatter plots each value at its label in input order and has no numeric x-axis. |
title | string | Up to 120 characters. Defaults to “Untitled chart”, which QA flags as missing-title. |
subtitle | string | Optional, up to 180 characters |
width | integer | 320–2400, defaults to 900 |
height | integer | 240–1600, defaults to 520 |
theme | string | light or dark, defaults to light |
showLegend | boolean | Defaults to true. Pie legends name the slices; without a legend, pie slices are labeled directly. |
showValues | boolean | Defaults to false. Prints each value on the chart. |
series | array | 1–12 series, each with 1–100 data points. Points are matched to categories by label, in first-seen order across all series. |
series[].name | string | 1–80 characters |
series[].color | string | Optional six-digit hex such as #6C5CE7 (three-digit hex is rejected). Ignored by pie charts, which color each slice from the palette. |
series[].data[].label | string | 1–80 characters. Axis labels longer than 12 characters are shortened. |
series[].data[].value | number | Any finite number, up to a magnitude of 1e15. A negative value in a pie chart is a QA error: the request is refused with 422 unless allowQualityErrors is set. |
allowQualityErrors | boolean | Defaults to false. Sent alongside the chart, but not part of it and not stored with the spec. When true, QA findings with severity error no longer block the request: the chart is stored, the render is counted, and the findings are still returned in warnings. |
Response
A successful request returns HTTP 201. The raw SVG is included for immediate use. The warnings array contains the QA findings that did not block the render — warnings and tips, each with a concrete correction. Unless you sent allowQualityErrors, a 201 also means QA found nothing blocking: a chart that violates the schema rules is refused before it is stored (see quality enforcement).
{
"id": "V1StGXR8_Z5jdH",
"svg": "<svg ...>...</svg>",
"altText": "Monthly recurring revenue...",
"warnings": [{
"severity": "tip",
"code": "healthy",
"message": "No structural chart problems detected.",
"suggestion": "Verify the source data..."
}],
"urls": {
"svg": "https://.../api/v1/charts/V1StGXR8_Z5jdH?format=svg",
"png": "https://.../api/v1/charts/V1StGXR8_Z5jdH?format=png"
},
"usage": { "used": 17, "limit": 100 }
}Quality enforcement
Every render is reviewed, and each finding carries a severity.
- error — the chart cannot represent the data it was given. Two rules produce one today:
pie-negative-valueandpie-multiple-series. The request is refused with422. Nothing is stored, no asset URL is issued, and the render is not counted against your quota. - warning and tip — advisory. The chart is stored and returned with
201, and the findings come back inwarningsso you can decide.
A 422 names every blocking finding with its code, message and suggested fix, so a client can correct the specification and retry in one step.
{
"error": "The chart was not stored: QA review found 1 blocking problem. Nothing was saved and no render was counted.",
"code": "quality-rejected",
"blocking": [{
"severity": "error",
"code": "pie-negative-value",
"message": "A pie chart cannot represent negative values.",
"suggestion": "Use a bar chart for signed values."
}],
"warnings": [ ... every finding, blocking or not ... ],
"override": {
"field": "allowQualityErrors",
"value": true,
"effect": "Resend the same request with this field set to store the chart despite these findings. It then counts as one render."
}
}If you want the chart despite the findings — reproducing an existing report, or storing something a human has already signed off — send "allowQualityErrors": true in the same object as the chart. Enforcement is downgraded for that request only: the chart is stored, the render is counted, and the same findings are returned in warnings. There is no account-level setting for this; the default is enforcement.
The public chart checker never enforces, because its purpose is showing you what is wrong. Its response marks each finding with blocksPublish and carries an enforcement object saying whether this API would have refused the chart.
Asset URLs
Generated assets are immutable. The only supported query parameter is format; any other parameter is rejected with 400. Use the SVG URL for responsive product interfaces and the PNG URL for email, social posts, and systems that reject SVG.
Charts in a URL
GET /chart carries the whole chart in the querystring and answers with the image itself, so a chart works wherever an image URL works and JavaScript does not: an HTML email, a README, a Slack message, a Notion page, a PDF, a spreadsheet cell, a no-code automation. It needs no account and no key, it is not metered, and nothing drawn through it counts against the monthly quota above. Integrations turns each of those surfaces into a copy-paste recipe.
# A single-series bar. Editorial style and PNG are the defaults.
https://chartrelay.dev/chart?data=18400,21300,24700,26900,31200,33900&labels=Apr,May,Jun,Jul,Aug,Sep&title=Monthly+recurring+revenue&prefix=$&source=Source:+finance+ledger
# Two series drawn as lines, returned as SVG, with the last point annotated.
https://chartrelay.dev/chart?type=line&data=12,18,22,31%7C9,11,14,16&names=North,South&labels=Q1,Q2,Q3,Q4&title=Pipeline+by+region&suffix=+deals¬e=South+flattened¬eAt=3&format=svg
# A pie, which the editorial renderer cannot draw: it comes back classic and x-chartrelay-note says so.
https://chartrelay.dev/chart?spec=eyJ0eXBlIjoicGllIiwidGl0bGUiOiJTaGFyZSBvZiByZXZlbnVlIiwic2VyaWVzIjpbeyJuYW1lIjoiU2hhcmUiLCJkYXRhIjpbeyJsYWJlbCI6IkRpcmVjdCIsInZhbHVlIjo0Mn0seyJsYWJlbCI6IlBhcnRuZXJzIiwidmFsdWUiOjMzfSx7ImxhYmVsIjoiTWFya2V0cGxhY2UiLCJ2YWx1ZSI6MjV9XX1dfQ
# The same chart without the chartrelay.dev mark, on a paid (Developer, Pro or Business) key.
https://chartrelay.dev/chart?data=18400,21300,24700,26900,31200,33900&title=Monthly+recurring+revenue&key=cr_live_...A vertical bar separates one series from the next. It is written %7C above because a bare | is not a legal URL character and strict clients reject it; the two are identical by the time the query is read, so write whichever your tool tolerates.
Two ways to say it
The readable form spells the chart out in ordinary parameters — data, labels, names — and is meant to be typed by hand or assembled by a tool that only knows how to concatenate strings. The spec form carries a whole chart as base64url-encoded JSON — the same object documented under chart schema, without the request-level options that only the JSON API reads — for anything the readable form cannot express.
When spec is present it wins outright: data, labels, names, type, title, subtitle, theme, w, h, legend and values are all ignored rather than merged, so there is never a question of which half of a half-written chart won. format, style, key and the presentation parameters — source, prefix, suffix, focus, note/noteAt — still apply.
# Any language that can base64url-encode JSON can build this parameter.
node -e 'console.log(Buffer.from(JSON.stringify({"type":"pie","title":"Share of revenue","series":[{"name":"Share","data":[{"label":"Direct","value":42},{"label":"Partners","value":33},{"label":"Marketplace","value":25}]}]})).toString("base64url"))'
# -> eyJ0eXBlIjoicGllIiwidGl0bGUiOiJTaGFyZSBvZiByZXZlbnVlIiwic2VyaWVzIjpbeyJuYW1lIjoiU2hhcmUiLCJkYXRhIjpbeyJsYWJlbCI6IkRpcmVjdCIsInZhbHVlIjo0Mn0seyJsYWJlbCI6IlBhcnRuZXJzIiwidmFsdWUiOjMzfSx7ImxhYmVsIjoiTWFya2V0cGxhY2UiLCJ2YWx1ZSI6MjV9XX1dfQParameters
| Parameter | Default | Rules |
|---|---|---|
data | Required | Comma-separated numbers; | (or %7C) starts another series. Every series needs at least one value and all of them need the same count. Decimals, negatives and the Unicode minus a spreadsheet emits are all accepted. A cell that is not a number is refused by position rather than drawn as zero, and there are no thousands separators — the comma already separates one value from the next, so 1,234 is two points. |
labels | 1, 2, 3… | One comma-separated label per value. Must match the number of values in a series exactly. |
names | See rules | One comma-separated name per series. Must match the number of series exactly. Left out, several series become Series 1, Series 2… and a single series takes the title, or Value when there is no title. |
type | bar | bar, line, area, pie or scatter — the same five the JSON API takes. Only bar and line are drawn in the editorial style; see styles. |
title | Untitled chart | Up to 120 characters. |
subtitle | None | Up to 180 characters. |
theme | light | light or dark. Both renderers honour it. |
w / h | 900 / 520 | Width 320–2400, height 240–1600. Classic style only. The editorial renderer draws on a fixed 860×480 canvas that its type sizes and paddings are tuned to, so it accepts these and does not use them. |
legend | 1 | 0 or false turns the legend off; any other value turns it on. Classic style only. The editorial renderer labels lines at their ends and draws its own legend when there is more than one series. |
values | 0 | Prints each value on the chart. 0 or false is off, any other value is on. Classic style only. The editorial column chart always labels its focus bar and nothing else. |
format | png | png or svg. Anything else is refused rather than silently defaulted. |
style | editorial | editorial or classic. Anything else is refused. See styles. |
key | None | An API key. On a paid workspace — Developer, Pro or Business — it removes the chartrelay.dev mark; see the free-tier mark. |
source | None | A credit line under the chart, e.g. Source: finance ledger. Editorial style only. |
prefix | None | Written before every number, e.g. $. Editorial single-series bar only. |
suffix | None | Written after every number, e.g. deals. Editorial line form only. A suffix also switches the axis back to full numbers, because a compacted 1.0Kms is not a unit anyone writes. |
focus | The last bar | Which bar carries the argument, counting from 0. Must be a whole number of 0 or more, and must name a bar that exists — an index past the last bar fails the render and you get the error image instead of a chart. Editorial single-series bar only: that bar is drawn in the series colour with its value printed above it, and the rest go quiet. |
note + noteAt | None | A leader-line annotation attached to one point of the first series. noteAt is required whenever note is given, counts from 0, and must name a point that exists — an index past the end fails the render and you get the error image instead of a chart. Editorial line form only. |
spec | None | A whole chart as base64url-encoded JSON, in the shape documented under chart schema. Padding is optional. Anything that is not valid base64url JSON, or is JSON the chart schema rejects, is refused with the schema's own message. |
The schema limits from chart schema apply to both forms: 1–12 series, 1–100 points each, labels and names of 1–80 characters, and values up to a magnitude of 1e15.
Editorial and classic
Two renderers draw the same numbers. The editorial one is the default and is the better chart — one focus bar in colour against quiet ones, lines labelled at their ends, room for a source line and an annotation. It draws only bar and line today.
- A pie, area or scatter asked for in the editorial style is drawn in the classic style instead, and the response says so in
x-chartrelay-note. It is never quietly turned into a chart type you did not ask for. - A bar chart with more than one series is drawn in the editorial style as lines, and says so in the same header. The editorial column chart carries one series by design, because its whole argument is a single bar against quiet ones.
style=classicpins the old drawing, so a page already built around it is not moved by a deployment. Classic honoursw,h,legendandvalues, and ignoressource,prefix,suffix,focusandnote.
The description of the chart does not change with the renderer: x-chartrelay-alt is the same sentence either way. The picture changes; the facts stated about it do not.
Response headers
| Header | When | Value |
|---|---|---|
x-chartrelay-alt | 200 | A sentence describing the chart from its data — the peak, the low, the first-to-last change, or the largest shares for a pie. Line breaks are collapsed to spaces so it is a legal header value. Read it back and put it in the alt attribute: an editorial SVG carries role="img" but no embedded <title> or <desc>, and a PNG cannot carry one at all, so this header is the only copy of the description that travels with the image. |
x-chartrelay-style | 200 | editorial or classic — the renderer that actually drew this image, which is not always the one asked for. |
x-chartrelay-note | 200, when there is something to say | Why the drawing is not the one you asked for, in a sentence you can show a user: an editorial request that fell back to classic, or a multi-series bar drawn as lines. Absent when the request was drawn as asked. |
x-chartrelay-error | 400 | The refusal in the same words printed on the error image, for a caller that can read headers. |
retry-after | 429 | Seconds until the rate-limit window resets. |
content-type | Always | image/png, or image/svg+xml; charset=utf-8 with format=svg. Refusals use the format that was asked for, so an <img> expecting a PNG still gets a PNG. |
cache-control | Always | public, max-age=31536000, s-maxage=31536000, immutable on a 200, no-store on a refusal. |
Errors come back as images
An <img> tag cannot show a JSON body. A caller who mistypes a parameter in an email template would get a broken-image icon and no idea why, so the reason is drawn as the image instead: a small card naming what was wrong and pointing back at this section. The status stays 4xx, so a programmatic caller still sees a failure, and x-chartrelay-error carries the same sentence.
The refusals are specific by design — "TBD" at position 2 is not a number, labels has 1 entries but data has 2 values per series, note needs noteAt, a whole number counting from 0, saying which point it points at. Read the card and fix the named parameter. A renderer that throws is reported as a 400 too, with the failure's own message rather than a blamed parameter.
This endpoint does not run the quality enforcement that POST /api/v1/charts runs. A chart with a structural problem is drawn, and the findings are not returned. If you want them, post the chart to the JSON API or paste it into the chart checker.
Query length, caching and rate limits
- 8 KB of querystring. Longer than that and the request is refused, because browsers, proxies and mail clients truncate long URLs at different undocumented lengths and a chart that renders for the author and breaks in someone else's inbox is worse than one that refuses up front. Past 8 KB,
POST /api/v1/chartsand use the short asset URL it returns — that is what the refusal tells you to do. - Cached hard. The URL is the chart, so the same URL can only ever produce the same image. Successful renders are served
immutablewith a one-year age.immutablematters more than the age: mail clients and Slack fetch through image proxies that would otherwise re-render the same chart for every recipient. - 240 requests a minute per IP address. Not a quota — an abuse ceiling, and not your plan's monthly renders. A chart drawn through this endpoint is not counted against that allowance as the endpoint stands today; anything drawn twice should be arriving from a cache rather than from the renderer. Over the ceiling you get a 429 with
retry-after. HEADis answered without drawing anything, which is what link unfurlers and mail proxies send first.
The free-tier mark
Free charts carry a small chartrelay.dev mark in the top-right corner, outside the plot. Removing it is one of the two things a paid plan buys; the other is the month's prepaid renders, which a chart drawn through this endpoint does not currently spend. Pass an API key from a paid workspace as key= and the chart is drawn without the mark — see pricing.
Errors
| Status | Meaning |
|---|---|
| 400 | Either the body could not be parsed as JSON — details.reason quotes the parser, and no field paths are given because no fields were read — or it parsed and failed validation, in which case details.issues lists each problem with the path to the field, such as series.1.data.3.label. |
| 401 | The Bearer API key is missing or invalid. |
| 404 | The requested asset does not exist. |
| 413 | The request body is larger than 256 KB. |
| 422 | The specification is valid JSON and valid against the schema, but QA found a blocking problem. See quality enforcement. Nothing was stored and no render was counted. |
| 429 | The workspace reached its monthly render limit. Only quota exhaustion returns 429. |
| 500 | A server error. It is not a quota problem and does not count against usage; retry later. |
Registration is not part of the v1 API and answers differently. 400 names the fields that are missing or invalid rather than describing the payload in general; 409 means an account already exists for that email; 415 means the request was not sent as application/json; 429 means too many accounts have been created from your network within the hour.
If a response still makes no sense after checking this table, support explains what to send us — and what never to include in a report.