MintRender
All docs pages

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.

https://img.mintrender.com/{template}/{size}.{png|jpg|webp|svg|pdf}?title=Hello&s={signature}
PartWhat 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=valueOne 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.
sThe signature. Because the link uses this key, a template param cannot be called s in a link.
  • A link answers GET and HEAD only. 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-Type and Cache-Control: public, max-age=3600.

Views, cache and credits

  1. The edge cache. A link that was opened in the last hour comes from Cloudflare's cache in that place. This costs nothing.

  2. 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.

  3. 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

  1. Encode each key and value as RFC 3986 (every byte except letters, digits and - _ . ~ becomes %XX). A space is %20, never +.

  2. Sort the encoded pairs by key, then by value, and join them as key=value with &. Leave s out.

  3. Sign {path}?{query} with HMAC-SHA256. The path is /{template}/{size}.{format}. The key is your link key, decoded from base64url (32 bytes).

  4. 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.

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}
FieldRule
templateRequired. A template id of your workspace.
sizeDefault main. The size must exist in the published version. A size that is only in the draft gives 400 unknown_size.
formatpng (default), jpg, webp, svg or pdf.
dataText values only. Keys at most 64 characters, values at most 2,048 characters. The key s is refused.
publishedIn 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.

StatusCodeWhen
400invalid_paramA parameter appears twice, a value does not fit the layer, or a size is unknown.
400unknown_paramA parameter matches no changeable layer.
400too_many_pagesA PDF link would have more than 10 pages.
400url_refused, url_failed, unsupported_type, image_too_largeAn image URL in the link or in the template cannot be used.
400limitThe output is too large, or the link uses too many image URLs. The message says which limit.
402no_creditsA new render is needed and no credits are left.
403unsignedThe link has no s.
403bad_signatureThe signature does not match the path and the values.
404not_foundThe path is not a link, the template does not exist, or it was deleted.
405method_not_allowedThe method is not GET or HEAD.
409not_publishedThe template has no published version.
429busyToo many renders at the same moment. Retry-After: 1.
451frozen, expiredThe workspace is frozen, or a reviewer workspace has ended.
500render_failedThe render failed. Open the link again.
503not_configuredSigned 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.