Signed links
A signed link is an image URL that renders when it is opened. Put it in an og:image tag, an email or an HTML img tag. Your code makes the link with your link key, and no API call is needed.
The link
https://img.mintrender.com/{template}/{size}.{png|jpg|webp|svg|pdf}?title=Hello&s={signature}| Part | What it is |
|---|---|
{template} | The template id, like tpl_3k2jQ8xLm0P4nB7vR2sT. The template must have a published version. Links draw the newest published version. |
{size} | The size name: main or one of the template's extra sizes. Names are lowercase letters, digits, _ and -. |
{png|jpg|…} | The format. The other options of POST /v1/render are fixed for links: scale 1 and quality 90. |
?key=value | One query parameter for each value you set, like data in the API. Values are text: a title, an image URL or a colour. Each key may appear once. |
s | The signature. Because the link uses this key, a template param cannot be called s in a link. |
- A link answers
GETandHEADonly. Other methods answer 405. - A PDF link makes at most 10 pages. For longer PDFs use
POST /v1/render. - Keys that start with
page2.and so on set a value on one page only, as in Template data. - The answer is the file, with its
Content-TypeandCache-Control: public, max-age=3600.
Views, cache and credits
The edge cache. A link that was opened in the last hour comes from Cloudflare's cache in that place. This costs nothing.
The stored file. If a file for the same template version, size, format, values and image bytes exists, the link sends it. This costs nothing and makes no render record.
A new render. Otherwise the link renders once and stores the file. This costs one credit, or one credit per page for a PDF. When no credits are left, the link answers 402.
After you publish a new version, links show it within one hour, as the edge cache entries end. Image URLs inside a link's values are fetched again at most once an hour. Views of a link send no webhook events.
The signature
Encode each key and value as RFC 3986 (every byte except letters, digits and
- _ . ~becomes%XX). A space is%20, never+.Sort the encoded pairs by key, then by value, and join them as
key=valuewith&. Leavesout.Sign
{path}?{query}with HMAC-SHA256. The path is/{template}/{size}.{format}. The key is your link key, decoded from base64url (32 bytes).Take the first 16 bytes and write them as base64url without padding (22 characters). That is
s. Only this exact spelling is accepted.
The signed string of the example below is /tpl_3k2jQ8xLm0P4nB7vR2sT/main.png?title=Hello%20world. The order of the parameters in the link you send does not matter, because the server sorts them the same way.
The link key
Each workspace has one link key. An owner sees it under Account, Link key, as 43 base64url characters. Keep it on your server, like a password. If it leaks, press "Make a new link key" there. Links signed with the old key stop working at once. Copies already in a browser or CDN cache can show for up to an hour.
Make a link with the API
POST /v1/links signs a link on our server with your API key, so the link key never leaves it. The editor's Code panel uses this route for its link.
curl
curl -X POST "https://mintrender.com/v1/links" \ -H "Authorization: Bearer $MINTRENDER_KEY" \ -H "Content-Type: application/json" \ -d '{"template":"tpl_3k2jQ8xLm0P4nB7vR2sT","size":"main","format":"png","data":{"title":"Hello world"}}'JavaScript
const res = await fetch("https://mintrender.com/v1/links", { method: "POST", headers: { Authorization: `Bearer ${process.env.MINTRENDER_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ template: "tpl_3k2jQ8xLm0P4nB7vR2sT", size: "main", format: "png", data: { title: "Hello world", }, }),});const body = await res.json();if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);console.log(body);Python
import os import requests res = requests.post( "https://mintrender.com/v1/links", headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"}, json={ "template": "tpl_3k2jQ8xLm0P4nB7vR2sT", "size": "main", "format": "png", "data": { "title": "Hello world", }, }, timeout=120,)body = res.json()if not res.ok: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")print(body){ "url": "https://img.mintrender.com/tpl_3k2jQ8xLm0P4nB7vR2sT/main.png?title=Hello%20world&s=…", "published": true}| Field | Rule |
|---|---|
template | Required. A template id of your workspace. |
size | Default main. The size must exist in the published version. A size that is only in the draft gives 400 unknown_size. |
format | png (default), jpg, webp, svg or pdf. |
data | Text values only. Keys at most 64 characters, values at most 2,048 characters. The key s is refused. |
published | In the answer: false means the template has no published version yet, so the link answers 409 until you publish. |
Errors
Errors from a link are JSON in the usual error format.
| Status | Code | When |
|---|---|---|
| 400 | invalid_param | A parameter appears twice, a value does not fit the layer, or a size is unknown. |
| 400 | unknown_param | A parameter matches no changeable layer. |
| 400 | too_many_pages | A PDF link would have more than 10 pages. |
| 400 | url_refused, url_failed, unsupported_type, image_too_large | An image URL in the link or in the template cannot be used. |
| 400 | limit | The output is too large, or the link uses too many image URLs. The message says which limit. |
| 402 | no_credits | A new render is needed and no credits are left. |
| 403 | unsigned | The link has no s. |
| 403 | bad_signature | The signature does not match the path and the values. |
| 404 | not_found | The path is not a link, the template does not exist, or it was deleted. |
| 405 | method_not_allowed | The method is not GET or HEAD. |
| 409 | not_published | The template has no published version. |
| 429 | busy | Too many renders at the same moment. Retry-After: 1. |
| 451 | frozen, expired | The workspace is frozen, or a reviewer workspace has ended. |
| 500 | render_failed | The render failed. Open the link again. |
| 503 | not_configured | Signed links are not set up on this server. |
Code
Set MINTRENDER_LINK_KEY to your link key. Each version prints a link for the title "Hello world". The JavaScript, Python, Go and Ruby versions are checked against the server's own signing code.
JavaScript
import { createHmac } from "node:crypto"; const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()); export function signedLink(template, size, format, data) { const path = `/${template}/${size}.${format}`; const pairs = Object.entries(data).map(([k, v]) => [enc(k), enc(String(v))]); pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)); const query = pairs.map(([k, v]) => `${k}=${v}`).join("&"); const key = Buffer.from(process.env.MINTRENDER_LINK_KEY, "base64url"); const s = createHmac("sha256", key).update(`${path}?${query}`).digest().subarray(0, 16).toString("base64url"); return `https://img.mintrender.com${path}?${query ? query + "&" : ""}s=${s}`;} console.log(signedLink("tpl_3k2jQ8xLm0P4nB7vR2sT", "main", "png", { title: "Hello world" }));Python
import base64, hashlib, hmac, osfrom urllib.parse import quote def enc(s): return quote(str(s), safe="-_.~") def signed_link(template, size, fmt, data): path = f"/{template}/{size}.{fmt}" pairs = sorted((enc(k), enc(v)) for k, v in data.items()) query = "&".join(f"{k}={v}" for k, v in pairs) key = base64.urlsafe_b64decode(os.environ["MINTRENDER_LINK_KEY"] + "==") mac = hmac.new(key, f"{path}?{query}".encode(), hashlib.sha256).digest()[:16] s = base64.urlsafe_b64encode(mac).rstrip(b"=").decode() return f"https://img.mintrender.com{path}?{query + '&' if query else ''}s={s}" print(signed_link("tpl_3k2jQ8xLm0P4nB7vR2sT", "main", "png", {"title": "Hello world"}))PHP
<?phpfunction signed_link(string $template, string $size, string $format, array $data): string { $path = "/$template/$size.$format"; $pairs = []; foreach ($data as $k => $v) { $pairs[] = [rawurlencode((string) $k), rawurlencode((string) $v)]; } usort($pairs, fn($a, $b) => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1])); $query = implode('&', array_map(fn($p) => $p[0] . '=' . $p[1], $pairs)); $key = base64_decode(strtr(getenv('MINTRENDER_LINK_KEY'), '-_', '+/')); $mac = substr(hash_hmac('sha256', "$path?$query", $key, true), 0, 16); $s = rtrim(strtr(base64_encode($mac), '+/', '-_'), '='); return "https://img.mintrender.com$path?" . ($query === '' ? '' : "$query&") . "s=$s";} echo signed_link('tpl_3k2jQ8xLm0P4nB7vR2sT', 'main', 'png', ['title' => 'Hello world']), "\n";Go
package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "net/url" "os" "sort" "strings") func enc(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } func signedLink(template, size, format string, data map[string]string) string { path := "/" + template + "/" + size + "." + format pairs := make([][2]string, 0, len(data)) for k, v := range data { pairs = append(pairs, [2]string{enc(k), enc(v)}) } sort.Slice(pairs, func(i, j int) bool { if pairs[i][0] != pairs[j][0] { return pairs[i][0] < pairs[j][0] } return pairs[i][1] < pairs[j][1] }) parts := make([]string, len(pairs)) for i, p := range pairs { parts[i] = p[0] + "=" + p[1] } query := strings.Join(parts, "&") key, _ := base64.RawURLEncoding.DecodeString(os.Getenv("MINTRENDER_LINK_KEY")) mac := hmac.New(sha256.New, key) mac.Write([]byte(path + "?" + query)) s := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) if query != "" { query += "&" } return "https://img.mintrender.com" + path + "?" + query + "s=" + s} func main() { fmt.Println(signedLink("tpl_3k2jQ8xLm0P4nB7vR2sT", "main", "png", map[string]string{"title": "Hello world"}))}Ruby
require "openssl" # RFC 3986: every byte except letters, digits and - _ . ~ becomes %XX.def enc(s) = s.to_s.b.gsub(/[^A-Za-z0-9-_.~]/n) { |c| format("%%%02X", c.ord) } def signed_link(template, size, format, data) path = "/#{template}/#{size}.#{format}" query = data.map { |k, v| [enc(k), enc(v)] }.sort.map { |k, v| "#{k}=#{v}" }.join("&") # pack and unpack, not the base64 gem (not built in since Ruby 3.4). key = (ENV.fetch("MINTRENDER_LINK_KEY").tr("-_", "+/") + "==").unpack1("m") mac = OpenSSL::HMAC.digest("SHA256", key, "#{path}?#{query}")[0, 16] s = [mac].pack("m0").tr("+/", "-_").delete("=") "https://img.mintrender.com#{path}?#{query.empty? ? "" : "#{query}&"}s=#{s}"end puts signed_link("tpl_3k2jQ8xLm0P4nB7vR2sT", "main", "png", { "title" => "Hello world" })Seen MintRender used for content that breaks our terms? Report it. Questions about the API: support@mintrender.com, with the request id.