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
- Take the URL's path (
/) and query string. - Parse the query as
application/x-www-form-urlencoded: split on&, split each pair on the first=, and decode names and values (%XXas UTF-8,+as a space). A pair without=has an empty value. - Keep only the known parameters listed below, and drop
sig. If a known parameter appears more than once, keep the first occurrence. - Sort the remaining pairs by name, byte by byte (so
c10sorts beforec2). - 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%XXwith upper-case hex. A space becomes%20. Empty values stay asname=. - The canonical string is the path, then
?, then the pairs asname=valuejoined by&. sigis HMAC-SHA256 of the canonical string (UTF-8) with the secret (UTF-8) as the key, encoded as base64url without padding (43 characters).- 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",
))
Expiring links
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