Browser dashboards draw charts with JavaScript. Email clients and PDF files do not run JavaScript, so the weekly summary in someone's inbox and the monthly report attached to it need the chart as an image. The Chart Generator renders that image from labels and numbers. The hard parts are around it: making sure the numbers you send are the numbers you meant, picking a chart the reader can actually read at 500 pixels wide, and getting the image to appear in mail clients that block remote images by default.
The request and the response
It is a POST endpoint, 10 credits per call. Two fields are required:
labels: an array of strings, the x-axis categories or the pie slices.datasets: an array of{ "name": ..., "values": [...] }, one per series.
Optional: type (bar, the default, line, pie, doughnut, radar or polarArea) and title. width, height and format (png, jpeg or svg) are premium; without them you get a 500 by 300 PNG.
curl -X POST "https://api.apiverve.com/v1/chartgenerator" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"bar","labels":["Jul","Aug","Sep"],"datasets":[{"name":"Orders","values":[120,145,160]},{"name":"Returns","values":[6,9,4]}],"title":"Orders and returns, Q3 2026"}'Two datasets share the labels: one bar of each colour per month.
The response carries an id, format (returned as ".png", with a leading dot), the chart type, expires in milliseconds and a signed downloadURL. When we called it, the link lasted about 24 hours. That is long enough to download the image or feed it into a PDF render, and nowhere near long enough to put in an email.
Validate before you pay for a wrong chart
The generator is forgiving in a way that will hurt you. We sent three labels with a dataset of only two values: it returned status: ok and a chart. We sent a value of "abc" in a pie chart: status: ok again. In both cases you pay 10 credits and get an image that quietly misrepresents the data. The only thing it reliably rejected was an unknown type.
So the check belongs on your side, and it is short:
const TYPES = new Set(['bar', 'line', 'pie', 'doughnut', 'radar', 'polarArea']);
export function validateChart({ type = 'bar', labels, datasets }) {
const problems = [];
if (!TYPES.has(type)) problems.push(`unknown type ${type}`);
if (!Array.isArray(labels) || labels.length === 0) problems.push('labels must be a non-empty array');
if (!Array.isArray(datasets) || datasets.length === 0) problems.push('datasets must be a non-empty array');
for (const ds of datasets ?? []) {
if (!Array.isArray(ds.values) || ds.values.length !== labels?.length) {
problems.push(`dataset "${ds.name}" has ${ds.values?.length ?? 0} values for ${labels?.length ?? 0} labels`);
} else if (!ds.values.every((v) => typeof v === 'number' && Number.isFinite(v))) {
problems.push(`dataset "${ds.name}" contains a non-numeric value`);
}
}
if (['pie', 'doughnut'].includes(type) && datasets?.length > 1) problems.push(`${type} charts show one dataset`);
if (problems.length) throw new Error(`Chart rejected: ${problems.join('; ')}`);
}
export async function renderChart(spec) {
validateChart(spec);
const res = await fetch('https://api.apiverve.com/v1/chartgenerator', {
method: 'POST',
headers: { 'x-api-key': process.env.APIVERVE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(spec),
signal: AbortSignal.timeout(20000),
});
const json = await res.json().catch(() => null);
if (json?.status !== 'ok') throw new Error(json?.error || `HTTP ${res.status}`);
const img = await fetch(json.data.downloadURL, { signal: AbortSignal.timeout(20000) }); // no API key
if (!img.ok) throw new Error(`Chart download failed: HTTP ${img.status}`);
return Buffer.from(await img.arrayBuffer());
}import math
import os
import requests
TYPES = {"bar", "line", "pie", "doughnut", "radar", "polarArea"}
def validate_chart(spec):
labels, datasets, kind = spec.get("labels") or [], spec.get("datasets") or [], spec.get("type", "bar")
problems = []
if kind not in TYPES:
problems.append(f"unknown type {kind}")
if not labels:
problems.append("labels must be a non-empty list")
if not datasets:
problems.append("datasets must be a non-empty list")
for ds in datasets:
values = ds.get("values") or []
if len(values) != len(labels):
problems.append(f"dataset {ds.get('name')!r} has {len(values)} values for {len(labels)} labels")
elif not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in values):
problems.append(f"dataset {ds.get('name')!r} contains a non-numeric value")
if kind in ("pie", "doughnut") and len(datasets) > 1:
problems.append(f"{kind} charts show one dataset")
if problems:
raise ValueError("Chart rejected: " + "; ".join(problems))
def render_chart(spec):
validate_chart(spec)
res = requests.post(
"https://api.apiverve.com/v1/chartgenerator",
json=spec,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=20,
)
body = res.json()
if body.get("status") != "ok":
raise RuntimeError(body.get("error") or f"HTTP {res.status_code}")
img = requests.get(body["data"]["downloadURL"], timeout=20) # no API key
img.raise_for_status()
return img.content
if __name__ == "__main__":
png = render_chart({"type": "line", "labels": ["Jul", "Aug", "Sep"],
"datasets": [{"name": "Signups", "values": [310, 342, 398]}],
"title": "Signups per month"})
with open("signups.png", "wb") as f:
f.write(png)The most common way to trip this check in real code is a missing period. Sales by month from a SQL GROUP BY simply has no row for a month with no sales, so the labels come from a calendar and the values from the query, and they disagree by one. Fill gaps with an explicit zero before building the chart; a zero bar tells the truth, a shifted series does not.
Your monthly report shows twelve month labels but the query returned eleven rows because March had no sales. What does the Chart Generator do if you send the eleven values unchanged?
Answer the check above to continue.
Getting charts into a PDF
For a PDF built from HTML, the situation flips: the renderer fetches images when it runs, so a remote URL is fine as long as it is still valid at render time. When we passed a chart's downloadURL as an <img src> to HTML to PDF, the chart was embedded in the resulting PDF. Two details make it reliable:
- Render right after generating. Generate the chart and call HTML to PDF in the same job, well inside the expiry window. A PDF render queued overnight may find the link gone.
- Escape the URL for HTML. Signed URLs contain
&between query parameters. Inside an attribute, write it as&. Most templating libraries do this for you; string concatenation does not.
If you already downloaded the PNG, you can also inline it as data:image/png;base64,.... PDF renderers, unlike email clients, handle data URIs well, and it removes the timing question entirely. The trade-off is size: base64 adds about a third to the image, and the HTML parameter accepts at most 500,000 characters, so a report with many large charts is better served by URLs.
Cache charts by their data
A chart is a pure function of its spec: same labels, same values, same title, same picture. Hash the JSON spec, use the hash as the filename, and skip the API call when the file exists. A daily report whose monthly chart only changes once a month then pays 10 credits a month for that chart instead of 300. The pipeline lesson wires this cache into a full report.
Sort the keys or build the spec in a fixed order before hashing, otherwise two identical charts serialised in different key orders hash differently and you pay twice.
One caution: never put personal data in a chart title or label you would not put in the report itself. The spec is sent to the API and the image sits on a signed link until it expires.
Next, all of these pieces join into one job: Build a document pipeline takes raw data to a stored, emailed PDF report, with retries and a running credit count.