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.
01The 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.
02Validate 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?
03Choose a chart the reader can read
A report chart is read once, quickly, at a fixed size, often on a phone. That rules out more than it rules in.
| The question the chart answers | Use | Avoid |
|---|---|---|
| How did this change over time? | line (or bar for a few periods) | pie |
| Which category is biggest? | bar, sorted largest first | radar, polarArea |
| What share does each part take? | pie or doughnut, five slices or fewer | Pie with twelve slivers |
| How do two series compare per period? | bar with two datasets | Two separate charts the reader must line up |
Some judgment that holds across all of them:
- Put the period and the unit in the title. "Revenue (USD thousands), Jul-Sep 2026" survives being forwarded without the email around it. "Revenue" does not.
- Collapse the long tail. For a share chart, keep the top four categories and add an "Other" slice for the rest. The reader cannot compare slices smaller than a few degrees anyway.
- Scale numbers before charting. 1240000 on an axis is harder to read than 1.24 with "(millions)" in the title.
- Labels are short. At 500 pixels wide, twelve labels of twenty characters collide. Use
Jan, notJanuary 2026. - Colour is not yours to set. There is no colour parameter, so do not write a caption that says "the red line". Refer to series by name, which the legend shows.
The radar and polarArea types exist for specific uses, such as comparing a handful of products across the same five attributes. In a business report they mostly make readers squint.
04Getting charts into email
Email is where most generated charts break, for three separate reasons.
The link expires. If the <img src> is the API's downloadURL, the chart vanishes after about a day. Anyone who opens the email later sees a broken image.
Remote images are often blocked. Many clients, Outlook especially, do not load remote images until the reader clicks to allow them, so your chart is a grey box with a "download pictures" banner.
Data URIs are unreliable. A src="data:image/png;base64,..." works in some clients and is stripped by others, Gmail among them.
The method that survives all three is an inline attachment referenced by Content-ID. The PNG travels inside the message, and the HTML points at it with cid:. Clients display it without fetching anything.
import nodemailer from 'nodemailer';
import { renderChart } from './chart.js';
const png = await renderChart({
type: 'bar',
labels: ['Jul', 'Aug', 'Sep'],
datasets: [{ name: 'Orders', values: [120, 145, 160] }],
title: 'Orders per month, Q3 2026',
});
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transport.sendMail({
from: 'reports@example.com',
to: process.env.REPORT_TO,
subject: 'Q3 orders: 425, up 33% on Q2',
text: 'Q3 orders: 425 (Jul 120, Aug 145, Sep 160), up 33% on Q2.',
html: `<p>Q3 orders: <strong>425</strong>, up 33% on Q2.</p>
<img src="cid:orders-q3" width="500" height="300"
alt="Bar chart: orders per month, July 120, August 145, September 160">
<table cellpadding="4"><tr><th align="left">Month</th><th align="right">Orders</th></tr>
<tr><td>Jul</td><td align="right">120</td></tr>
<tr><td>Aug</td><td align="right">145</td></tr>
<tr><td>Sep</td><td align="right">160</td></tr></table>`,
attachments: [{ filename: 'orders-q3.png', content: png, cid: 'orders-q3' }],
});Install it with npm install nodemailer in a project that has "type": "module", set the SMTP variables for whichever provider you send through, and run it with node send-summary.js.
Look at what the message does besides the image. The subject line carries the headline number, because most people never open the email. The alt text states the data, so a screen reader or a blocked image still delivers it. The table repeats the numbers, so the email is complete even if the image never shows. The chart is the fastest way to see the shape; it should never be the only place the numbers live.
Explicit width and height attributes stop the layout jumping while the image loads, and in Outlook they stop it from rendering the image at a surprising size.
Your weekly email embeds the chart with <img src="(the API's downloadURL)">. It looks perfect in your test send. What will recipients most likely report?
05Getting 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.
06Cache 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.