Version 1

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.

Terminal
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"
  }'
Every field accepted by POST /api/auth/register. Scroll sideways on a narrow screen.
FieldTypeRules
namestringRequired. 2–80 characters after trimming. Names your workspace.
emailstringRequired. A valid address, lower-cased on the way in. One account per address.
passwordstringRequired. 10–128 characters.
sourcestringOptional 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:

200 OK
{
  "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.

Terminal
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

Every field accepted by POST /api/v1/charts. Scroll sideways on a narrow screen.
FieldTypeRules
typestringbar, 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.
titlestringUp to 120 characters. Defaults to “Untitled chart”, which QA flags as missing-title.
subtitlestringOptional, up to 180 characters
widthinteger320–2400, defaults to 900
heightinteger240–1600, defaults to 520
themestringlight or dark, defaults to light
showLegendbooleanDefaults to true. Pie legends name the slices; without a legend, pie slices are labeled directly.
showValuesbooleanDefaults to false. Prints each value on the chart.
seriesarray1–12 series, each with 1–100 data points. Points are matched to categories by label, in first-seen order across all series.
series[].namestring1–80 characters
series[].colorstringOptional six-digit hex such as #6C5CE7 (three-digit hex is rejected). Ignored by pie charts, which color each slice from the palette.
series[].data[].labelstring1–80 characters. Axis labels longer than 12 characters are shortened.
series[].data[].valuenumberAny 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.
allowQualityErrorsbooleanDefaults 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).

201 Created
{
  "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.

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.

422 Unprocessable Content
{
  "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.

GET /chart
# 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&note=South+flattened&noteAt=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.

Terminal
# 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"))'
# -> eyJ0eXBlIjoicGllIiwidGl0bGUiOiJTaGFyZSBvZiByZXZlbnVlIiwic2VyaWVzIjpbeyJuYW1lIjoiU2hhcmUiLCJkYXRhIjpbeyJsYWJlbCI6IkRpcmVjdCIsInZhbHVlIjo0Mn0seyJsYWJlbCI6IlBhcnRuZXJzIiwidmFsdWUiOjMzfSx7ImxhYmVsIjoiTWFya2V0cGxhY2UiLCJ2YWx1ZSI6MjV9XX1dfQ

Parameters

Every parameter GET /chart reads. Scroll sideways on a narrow screen.
ParameterDefaultRules
dataRequiredComma-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.
labels1, 2, 3…One comma-separated label per value. Must match the number of values in a series exactly.
namesSee rulesOne 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.
typebarbar, line, area, pie or scatter — the same five the JSON API takes. Only bar and line are drawn in the editorial style; see styles.
titleUntitled chartUp to 120 characters.
subtitleNoneUp to 180 characters.
themelightlight or dark. Both renderers honour it.
w / h900 / 520Width 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.
legend10 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.
values0Prints 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.
formatpngpng or svg. Anything else is refused rather than silently defaulted.
styleeditorialeditorial or classic. Anything else is refused. See styles.
keyNoneAn API key. On a paid workspace — Developer, Pro or Business — it removes the chartrelay.dev mark; see the free-tier mark.
sourceNoneA credit line under the chart, e.g. Source: finance ledger. Editorial style only.
prefixNoneWritten before every number, e.g. $. Editorial single-series bar only.
suffixNoneWritten 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.
focusThe last barWhich 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 + noteAtNoneA 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.
specNoneA 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.

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

Headers returned by GET /chart. Scroll sideways on a narrow screen.
HeaderWhenValue
x-chartrelay-alt200A 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-style200editorial or classic — the renderer that actually drew this image, which is not always the one asked for.
x-chartrelay-note200, when there is something to sayWhy 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-error400The refusal in the same words printed on the error image, for a caller that can read headers.
retry-after429Seconds until the rate-limit window resets.
content-typeAlwaysimage/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-controlAlwayspublic, 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

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 codes returned by the v1 API. Scroll sideways on a narrow screen.
StatusMeaning
400Either 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.
401The Bearer API key is missing or invalid.
404The requested asset does not exist.
413The request body is larger than 256 KB.
422The 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.
429The workspace reached its monthly render limit. Only quota exhaustion returns 429.
500A 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.