DocsChart URLs

Signing chart URLs

Signed URLs render without a watermark on paid plans, and they cannot be tampered with: changing any value invalidates the signature. Sign URLs on your server with your secret key. Never put the secret in a browser, an email or a spreadsheet.

Each public key (rc_pub_live_...) has its own secret (rc_sec_live_...). Locally the prefixes are rc_pub_test_ and rc_sec_test_. Rotating a key issues a new pair; the old pair keeps working for a grace period (24 hours by default).

The dashboard's "copy signed URL" button and POST /api/v1/sign do all of this for you.

The algorithm

  1. Take the URL's path (/) and query string.
  2. Parse the query as application/x-www-form-urlencoded: split on &, split each pair on the first =, and decode names and values (%XX as UTF-8, + as a space). A pair without = has an empty value.
  3. Keep only the known parameters listed below, and drop sig. If a known parameter appears more than once, keep the first occurrence.
  4. Sort the remaining pairs by name, byte by byte (so c10 sorts before c2).
  5. Encode each value with RFC 3986 percent-encoding: leave A-Z, a-z, 0-9, -, _, . and ~ as they are and encode every other byte of the UTF-8 form as %XX with upper-case hex. A space becomes %20. Empty values stay as name=.
  6. The canonical string is the path, then ?, then the pairs as name=value joined by &.
  7. sig is HMAC-SHA256 of the canonical string (UTF-8) with the secret (UTF-8) as the key, encoded as base64url without padding (43 characters).
  8. Append &sig=... to the original URL. The URL may keep any parameter order and any unknown parameters.

Known parameters: key, type, x, y, y1 to y10, n, n1 to n10, sep, title, xlabel, ylabel, w, h, dpr, bg, c, c1 to c10, theme, legend, stacked, values, smooth, min, max, format, cfg and exp.

Example:

URL:        /?type=bar&x=Mon,Tue&y=1,2&key=rc_pub_test_0000000000000000abcd&utm_source=newsletter
Canonical:  /?key=rc_pub_test_0000000000000000abcd&type=bar&x=Mon%2CTue&y=1%2C2

Verification

The server rebuilds the canonical string from the request, computes the HMAC with the key's secret, and compares it with sig in constant time. sig is read from its last occurrence, because signing appends it: re-signing a URL that still carries an old sig works. Every other parameter uses its first occurrence. If exp is present, the signature must also be current: once exp (Unix seconds) has passed the URL returns the 401 image. exp is ignored on unsigned URLs.

Test vectors

infrastructure/test/fixtures/signing-vectors.json holds the shared cases that both the PHP and the TypeScript implementations must pass. Each case gives a secret, a URL, the expected canonical string, the expected sig, the fully signed URL, and a now timestamp with the expected verification result.

PHP

<?php

declare(strict_types=1);

function renderchart_canonical(string $url): string
{
    $known = array_merge(
        ['key', 'type', 'x', 'y', 'n', 'sep', 'title', 'xlabel', 'ylabel', 'w', 'h', 'dpr', 'bg', 'c',
            'theme', 'legend', 'stacked', 'values', 'smooth', 'min', 'max', 'format', 'cfg', 'exp'],
        array_map(fn (int $i): string => "y{$i}", range(1, 10)),
        array_map(fn (int $i): string => "n{$i}", range(1, 10)),
        array_map(fn (int $i): string => "c{$i}", range(1, 10)),
    );

    $parts = parse_url($url);
    $path = $parts['path'] ?? '/';
    $pairs = [];

    foreach (explode('&', $parts['query'] ?? '') as $pair) {
        if ($pair === '') {
            continue;
        }
        [$name, $value] = array_pad(explode('=', $pair, 2), 2, '');
        $name = urldecode($name);
        if ($name === 'sig' || ! in_array($name, $known, true) || array_key_exists($name, $pairs)) {
            continue;
        }
        $pairs[$name] = urldecode($value);
    }

    ksort($pairs, SORT_STRING);

    $encoded = [];
    foreach ($pairs as $name => $value) {
        $encoded[] = $name.'='.rawurlencode($value);
    }

    return $path.'?'.implode('&', $encoded);
}

function renderchart_sign(string $url, string $secret): string
{
    $mac = hash_hmac('sha256', renderchart_canonical($url), $secret, true);
    $sig = rtrim(strtr(base64_encode($mac), '+/', '-_'), '=');

    return $url.(str_contains($url, '?') ? '&' : '?').'sig='.$sig;
}

echo renderchart_sign(
    'https://c.renderchart.com/?key=rc_pub_live_xxxxxxxxxxxxxxxxxxxx&type=bar&x=Mon,Tue&y=1,2',
    'rc_sec_live_YOUR_SECRET',
);

Node

const crypto = require('node:crypto');

const KNOWN = new Set([
  'key', 'type', 'x', 'y', 'n', 'sep', 'title', 'xlabel', 'ylabel', 'w', 'h', 'dpr', 'bg', 'c',
  'theme', 'legend', 'stacked', 'values', 'smooth', 'min', 'max', 'format', 'cfg', 'exp',
  ...Array.from({ length: 10 }, (_, i) => `y${i + 1}`),
  ...Array.from({ length: 10 }, (_, i) => `n${i + 1}`),
  ...Array.from({ length: 10 }, (_, i) => `c${i + 1}`),
]);

const rfc3986 = (value) =>
  encodeURIComponent(value).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

function renderchartCanonical(url) {
  const u = new URL(url);
  const pairs = new Map();
  for (const [name, value] of u.searchParams) {
    if (name === 'sig' || !KNOWN.has(name) || pairs.has(name)) continue;
    pairs.set(name, value);
  }
  const names = [...pairs.keys()].sort();
  return u.pathname + '?' + names.map((name) => `${name}=${rfc3986(pairs.get(name))}`).join('&');
}

function renderchartSign(url, secret) {
  const sig = crypto.createHmac('sha256', secret).update(renderchartCanonical(url)).digest('base64url');
  return url + (url.includes('?') ? '&' : '?') + 'sig=' + sig;
}

console.log(
  renderchartSign(
    'https://c.renderchart.com/?key=rc_pub_live_xxxxxxxxxxxxxxxxxxxx&type=bar&x=Mon,Tue&y=1,2',
    'rc_sec_live_YOUR_SECRET',
  ),
);

Python

import base64
import hashlib
import hmac
from urllib.parse import parse_qsl, quote, urlsplit

KNOWN = {
    "key", "type", "x", "y", "n", "sep", "title", "xlabel", "ylabel", "w", "h", "dpr", "bg", "c",
    "theme", "legend", "stacked", "values", "smooth", "min", "max", "format", "cfg", "exp",
    *(f"y{i}" for i in range(1, 11)),
    *(f"n{i}" for i in range(1, 11)),
    *(f"c{i}" for i in range(1, 11)),
}


def renderchart_canonical(url: str) -> str:
    parts = urlsplit(url)
    pairs: dict[str, str] = {}
    for name, value in parse_qsl(parts.query, keep_blank_values=True):
        if name == "sig" or name not in KNOWN or name in pairs:
            continue
        pairs[name] = value
    encoded = [f"{name}={quote(pairs[name], safe='-_.~')}" for name in sorted(pairs)]
    return (parts.path or "/") + "?" + "&".join(encoded)


def renderchart_sign(url: str, secret: str) -> str:
    mac = hmac.new(secret.encode(), renderchart_canonical(url).encode(), hashlib.sha256).digest()
    sig = base64.urlsafe_b64encode(mac).rstrip(b"=").decode()
    return url + ("&" if "?" in url else "?") + "sig=" + sig


print(renderchart_sign(
    "https://c.renderchart.com/?key=rc_pub_live_xxxxxxxxxxxxxxxxxxxx&type=bar&x=Mon,Tue&y=1,2",
    "rc_sec_live_YOUR_SECRET",
))

Add exp before signing to make a link stop working at a given time, for example a chart in a one-off report:

/?key=...&type=line&x=1,2,3&y=4,5,6&exp=1767225600&sig=...

Common mistakes

  • Signing after adding tracking parameters is fine: unknown parameters are ignored. Signing and then changing a known parameter is not.
  • Encode a literal + as %2B. A bare + in a URL is a space.
  • Do not sign short URLs (/s/{id}). They carry no key and need no signature.

Next: Short URLs