Docs menu
API
Every endpoint, response headers, usage, errors and limits.
The API lives at https://api.chartshot.dev. A chart is a GET request, so it works anywhere an image URL does.
Authentication
Chart URLs work without a key, with the "chartshot.dev" badge. Everything else takes a key from the Keys page: send the secret key as Authorization: Bearer sk_live_…, or sign a chart URL with it. Keys and signed URLs explains both.
GET /v1/chart
Draws the chart a spec describes and returns the image.
| Parameter | Value |
|---|---|
c | The spec: base64url-encoded JSON, with or without = padding, or plain JSON that starts with {. Required. |
errors | image (the default) sends an error as an image. status sends it as JSON with its real HTTP status. |
k, exp, sig | A signed URL: your public id, an optional expiry in Unix seconds, and the signature. |
Any other parameter is an error, so a typo doesn't go unnoticed. A secret key (sk_…) in the URL is refused, and should be rolled.
curl -o chart.png "https://api.chartshot.dev/v1/chart?c=eyJ0eXBlIjoiYmFyIiwiZGF0YSI6eyJsYWJlbHMiOlsiTW9uIiwiVHVlIl0sInNlcmllcyI6W3sibmFtZSI6IlNpZ251cHMiLCJ2YWx1ZXMiOlsxMiwxOF19XX19"The image's format is the spec's format: png (the default), svg or webp. An SVG can't run scripts or load anything when opened on its own.
POST /v1/render
Draws the spec in the body (JSON, up to 64 KB) and returns the image, for code that handles the image itself. It takes a secret key, and its errors are always JSON.
curl https://api.chartshot.dev/v1/render \
-H "Authorization: Bearer $CHARTSHOT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d @spec.json -o chart.pngPOST /v1/charts
Draws the spec in the body and stores the image at a permanent URL, for places that keep a link for a long time. Stored charts come with Pro and up, and are kept for 90 days on Pro and a year on Team and Business.
curl https://api.chartshot.dev/v1/charts \
-H "Authorization: Bearer $CHARTSHOT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d @spec.json{
"id": "7fQ2mZk1Lr8xWc3NvB0pYd",
"url": "https://c.chartshot.dev/7fQ2mZk1Lr8xWc3NvB0pYd.png",
"format": "png",
"width": 600,
"height": 300,
"alt": "Bar chart. Signups, highest 18 (Tue), lowest 12 (Mon).",
"expiresAt": "2027-01-22T09:30:00.000Z"
}The id is random, so the URL can't be guessed. DELETE /v1/charts/{id} deletes a chart, and its URL stops working within a minute. The dashboard lists your stored charts.
GET /v1/usage
This month's renders for your account, every key together:
{
"plan": "pro",
"period": { "start": "2026-10-01", "end": "2026-11-01" },
"renders": { "used": 1240, "included": 10000, "limit": 11000, "remaining": 8760 },
"days": [{ "date": "2026-10-01", "renders": 310 }]
}days lists the days with renders. Counts can lag by up to a minute.
Saved themes
Save a theme once and use it by id in any spec drawn with your key: "theme": "theme_…". A saved theme is the same object as an inline theme (extends, font, light, dark). The dashboard has an editor that imports a shadcn globals.css.
| Request | What it does |
|---|---|
GET /v1/themes | Lists your themes, oldest first, as { "data": [...] }. |
POST /v1/themes | Saves { "name": "Brand", "theme": { … } } and returns it with its id. |
GET /v1/themes/{id} | One theme. |
PATCH /v1/themes/{id} | Changes its name, theme or both. Charts drawn after that use the new colours. |
DELETE /v1/themes/{id} | Deletes it. Specs that name it then get theme_not_found. |
Pro saves three themes, Team and Business any number. Keyless chart URLs can't use saved themes.
Usage
Renders are counted per calendar month in UTC, for every plan, so the count resets on the 1st. Each chart a key gets counts once, whether it was drawn or came from the API's memory; a 304 and an error don't count, and neither do copies the CDN serves.
Past your plan's renders, charts keep coming for another 10%. After that, POST requests get usage_limit_reached (402) and signed URLs an image saying the limit is reached, until the month ends or you upgrade. The account's email gets a note at 80% and at 100%.
| Plan | Renders a month | With the 10% grace | A minute per key | Saved themes | Stored charts kept | Signed URLs |
|---|---|---|---|---|---|---|
| Free | 500 | 550 | 60 | Built-in only | None | No |
| Pro | 10,000 | 11,000 | 300 | 3 | 90 days | Yes |
| Team | 50,000 | 55,000 | 300 | Unlimited | 1 year | Yes |
| Business | 250,000 | 275,000 | 300 | Unlimited | 1 year | Yes |
Response headers
| Header | What it holds |
|---|---|
Content-Type | image/png, image/svg+xml or image/webp. |
Cache-Control | public, max-age=3600 for keyless URLs, a year (or until exp) for signed URLs, no-store for POST. Error responses are never cached. |
ETag | A hash of the spec and the renderer version. Send it back as If-None-Match to get a 304 while the chart is unchanged. |
X-Chart-Alt | Alt text describing the chart, such as Bar chart. Signups, highest 18 (Tue), lowest 12 (Mon). Set the spec's alt to use your own. |
X-Chart-Warnings | Fields the spec doesn't know, which were ignored, each with a hint. Present only when there are some. |
X-RateLimit-Limit, X-RateLimit-Remaining | Requests allowed a minute (from your IP without a key, per key with one), and how many are left. Retry-After comes with a 429. |
X-Usage-Limit, X-Usage-Remaining | With a key: your plan's renders this month, and how many are left. |
X-Error-Code | The error's code, on error images and JSON alike. |
X-Request-Id | Our id for the request. Quote it if you report a problem. |
Warnings
A field the spec doesn't know doesn't stop the chart. It is drawn without that field, and X-Chart-Warnings says what was ignored. Putting title at the top level instead of in options gives:
X-Chart-Warnings: title: Not a field, so it was ignored. Did you mean options.title?Several warnings are joined with ; . Each one is percent-encoded on its own, ; included, so split on ; and decode each part (decodeURIComponent in JavaScript) to get the text back.
The playground shows the same warnings beside the editor.
Errors
An <img> can't show JSON, and shows any non-200 response as a broken image. So by default an error comes back as an image with status 200: a short message drawn at the size the spec asked for, with the code in X-Error-Code.
Add errors=status while you debug a URL, and the error comes back as JSON with its real status. Requests with a secret key always get JSON. issues lists each field at fault by its path in the spec:
{
"error": {
"code": "invalid_spec",
"message": "data.series[0].values: Expected 2 values, one per label, got 1",
"issues": [
{ "path": "data.series[0].values", "message": "Expected 2 values, one per label, got 1" }
]
}
}Error codes never change their meaning, so code can check them:
| Code | Status | Message |
|---|---|---|
invalid_spec | 400 | The chart spec isn't valid. Check the listed fields. |
invalid_json | 400 | The chart spec isn't valid JSON. |
invalid_encoding | 400 | The c parameter must be base64url-encoded JSON, or plain JSON starting with {. |
spec_too_large | 413 | The chart spec is over 64 KB. Send fewer values or shorter labels. |
query_too_large | 414 | The URL is over 8 KB. POST the spec, or store the chart and use its short URL. |
invalid_request | 400 | The request isn't valid. Check the listed fields. |
theme_not_found | 400 | The saved theme wasn't found. Check the theme id. |
missing_api_key | 401 | Add your secret key as an Authorization: Bearer header. |
invalid_api_key | 401 | This key isn't valid. It may have been rolled. |
invalid_signature | 401 | The URL doesn't match its signature. Sign it again, and send the signed text as it is, then &sig=. |
signature_expired | 401 | This signed URL has expired. Sign a new one. |
secret_key_in_url | 400 | Secret keys must never go in a URL. Roll this key now, then sign URLs instead. |
plan_upgrade_required | 403 | Your plan doesn't include this. Upgrade to use it. |
usage_limit_reached | 402 | This month's renders are used up. Upgrade, or wait for the next period. |
rate_limited | 429 | Too many requests. Try again in a minute. |
forbidden | 403 | Requests must go through api.chartshot.dev. |
not_found | 404 | Nothing was found here. |
render_timeout | 500 | Drawing this chart took too long. Try again, or send fewer values. |
server_busy | 503 | Too many charts are being drawn right now. Try again. |
internal_error | 500 | Something went wrong on our side. Try again. |
A series of only null values, a single value and null gaps are not errors: each draws a chart. A null leaves a gap in a line, and a lone value is drawn as a dot.
Limits
Each limit exists for a reason on the server or in an image format. Anything else a spec asks for is drawn.
| Limit | Value | Why |
|---|---|---|
| Query string | 8 KB | Some proxies and clients refuse longer URLs. |
| Values in one chart | 5,000 | Labels × series, slices, rings, points or sparkline values. More would take longer to draw than the render timeout allows. |
| Width and height | 1 to 5,000 px | WebP can't be over 16,383 px a side, even at scale 3. |
| Output pixels | 16m (5m for WebP, 5m without a key) | Width × height × scale², which sets how much memory and time a render takes. |
| Alt text | 1,000 characters | It comes back in the X-Chart-Alt header, and headers have a size limit. |
| Keyless requests | 60 a minute per IP | Images the CDN already has don't count. |
| Requests with a key | Your plan's, a minute per key | One key can't take every render thread. See the plans table. |
POST /mcp
Chartshot's MCP server, for AI assistants, at https://api.chartshot.dev/mcp. It takes a secret key as Authorization: Bearer sk_live_…, like the other routes, and charts drawn through it count against your plan.
GET /v1/schema
Returns the spec's JSON Schema (draft 2020-12). Point an editor at it for autocomplete, or give it to a language model. It allows requests from any origin and is cached for an hour.
curl https://api.chartshot.dev/v1/schemaThe spec reference is built from the same schema.