chartshot
Docs menu

Keys and signed URLs

Render with your secret key, sign chart URLs for emails and Slack, and roll a key.

Sign in and create a key on the Keys page. A key has two parts:

PartLooks likeWhere it goes
Secret keysk_live_ and 32 charactersYour server only: the Authorization header, and signing URLs. The dashboard shows it once, when you create or roll the key.
Public idpk_live_ and 24 charactersThe k parameter of signed URLs. It's safe to share.

Charts made with a key count against your plan's renders, and on paid plans they have no badge.

Render with your key

Send the spec as the body of POST /v1/render, with the secret key as a bearer token:

Shell
curl https://api.chartshot.dev/v1/render \
  -H "Authorization: Bearer $CHARTSHOT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"bar","data":{"labels":["Mon","Tue"],"series":[{"name":"Signups","values":[12,18]}]}}' \
  -o chart.png

Use it where your code handles the image itself: attaching it to an email, uploading it, or saving it. Errors come back as JSON with each field at fault, as in the API reference.

Signed URLs

A signed URL is a chart URL made with your key, for places that load an image by URL, such as an email or a Slack message. It draws without the badge, counts against your plan, and is cached for a year, or until it expires. Anyone with the URL can see the chart, but changing any part of it breaks the signature. Signed URLs come with Pro and up.

In JavaScript or TypeScript, the SDK signs them for you:

TypeScript
import { Charts } from "@lglab/chartshot";

const charts = new Charts({
  secretKey: process.env.CHARTSHOT_SECRET_KEY!,
  publicId: process.env.CHARTSHOT_PUBLIC_ID,
});
const url = charts.signUrl(spec, { expiresIn: "90d" });

In any other language, to sign a URL:

  1. Make the key's signing key: HMAC-SHA256 of the text chartshot-sign, keyed with the secret key.
  2. Set the parameters: c (the spec, base64url-encoded JSON), k (the public id) and, if the URL should stop working at some point, exp (Unix time in seconds). errors can be signed too.
  3. Percent-encode each name and value (RFC 3986: everything except A–Z a–z 0–9 - . _ ~), sort them by name, and join them as name=value pairs with &.
  4. Add &sig= and the base64url HMAC-SHA256 of that text, keyed with the signing key, without = padding. The URL's query is that text exactly: one signature fits one URL, so a reordered or re-encoded copy is refused.
sign.mjs
import { createHmac } from "node:crypto";

const secret = process.env.CHARTSHOT_SECRET_KEY; // sk_live_…
const publicId = process.env.CHARTSHOT_PUBLIC_ID; // pk_live_…
const signingKey = createHmac("sha256", secret).update("chartshot-sign").digest();

/** RFC 3986: everything but A–Z a–z 0–9 - . _ ~ is percent-encoded. */
const encode = (text) =>
  encodeURIComponent(text).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);

export function signedChartUrl(spec, { expiresAt } = {}) {
  const params = new URLSearchParams({
    c: Buffer.from(JSON.stringify(spec)).toString("base64url"),
    k: publicId,
  });
  if (expiresAt) params.set("exp", String(Math.floor(expiresAt.getTime() / 1000)));
  const signed = [...params]
    .map(([name, value]) => [encode(name), encode(value)])
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([name, value]) => `${name}=${value}`)
    .join("&");
  const sig = createHmac("sha256", signingKey).update(signed).digest("base64url");
  return `https://api.chartshot.dev/v1/chart?${signed}&sig=${sig}`;
}
sign.py
import base64, hashlib, hmac, json, os, time
from urllib.parse import quote

secret = os.environ["CHARTSHOT_SECRET_KEY"]  # sk_live_…
public_id = os.environ["CHARTSHOT_PUBLIC_ID"]  # pk_live_…
signing_key = hmac.new(secret.encode(), b"chartshot-sign", hashlib.sha256).digest()

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def signed_chart_url(spec: dict, expires_at: float | None = None) -> str:
    params = {"c": b64url(json.dumps(spec, separators=(",", ":")).encode()), "k": public_id}
    if expires_at is not None:
        params["exp"] = str(int(expires_at))
    signed = "&".join(f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in sorted(params.items()))
    sig = b64url(hmac.new(signing_key, signed.encode(), hashlib.sha256).digest())
    return f"https://api.chartshot.dev/v1/chart?{signed}&sig={sig}"

A signed URL that doesn't check out is refused with one of these codes, as an image or, with errors=status, as JSON:

CodeWhy
invalid_signatureThe URL changed after it was signed, its parameters aren't in the signed order, or it was signed with another key.
signature_expiredexp has passed.
invalid_api_keyNo live key has this public id: it was rolled or revoked.
plan_upgrade_requiredThe key's plan doesn't include signed URLs.

Roll or revoke a key

Rolling a key gives it a new secret key, public id and signing key. The old secret and every URL signed with it stop working within a minute. Revoking a key ends it for good.

To change keys without a gap, create a second key, move your code to it, then revoke the first.

Keep the secret key out of browsers, URLs and repositories. A URL that contains sk_ is refused with secret_key_in_url; roll that key.