DynamicDocumentAPI

Signed links

Image and PDF URLs that render a template on request, without an API key. URL format, HMAC signatures, open links, checks and errors, caching, quotas and billing.

View as Markdown

A signed link turns a published template into a URL. Put the URL in an <img> tag, an email, an Open Graph tag or a no-code tool, and every request renders the template with the parameters in the URL. There is no API key in the URL: the link's secret signs the URL instead, so nobody can change the parameters without invalidating it.

https://img.dynamicdocumentapi.com/l/lnk_01J9ZM1X3F7R8K2C4V6B8N0P2Q/banner.png?title.text=Summer%20Sale&exp=1789661194&sig=BsM0_V6iRJeeRIjIhywJ3zFHrW6j0plh…

The first request renders the template's live version. After that the result comes from the cache until the template is published again, and a cache hit costs no render. Signed links are included from the Starter plan.

Create links in the dashboard under Signed links, or with a key that has the signed_links:write scope:

curl https://api-eu.dynamicdocumentapi.com/v1/signed-links \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C",
    "name": "Summer campaign banners",
    "formats": ["png", "webp"],
    "allowed_params": ["title.text", "hero.src"],
    "defaults": {"title.text": "Summer Sale"},
    "param_limits": {"title.text": {"max_length": 80}, "hero.src": {"max_length": 300}},
    "quota_total": 10000,
    "cache_ttl_seconds": 86400
  }'

The response contains base_url and the link's secret. The secret is shown once: store it next to your API keys. Reads never return it again.

FieldDescription
template_idThe template to render. It must have a published version; the link always renders the live one. Can't be changed later.
formatsExtensions the URL may use: png, jpeg (also as .jpg), webp, pdf
allowed_paramsThe parameters a URL may set. Anything else is rejected.
defaultsValues used when the URL leaves a parameter out
param_limitsPer parameter, {"max_length": n}. Required for every allowed parameter of an open link.
accesssigned (default) or open, see Open links
expires_atAfter this time every URL of the link answers 410 link_expired
quota_totalMaximum number of billed renders (cache misses) for the link
rate_limit_per_ip_per_hourRequests per client IP address and hour. Unlimited by default for signed links, 120 for open links.
allowed_referrersSites that may embed the link, as exact hosts (example.com) or *.example.com for subdomains
cache_ttl_secondsHow long a result stays cached. By default until the template is published again, at most 30 days.
fallback_image_asset_idAn image asset served instead of an error when your workspace has no renders left
enabledSet to false to stop serving without deleting the link

GET, PATCH and DELETE /v1/signed-links/{id} read, change and delete a link. The link object also reports quota_used, the number of billed renders so far.

The URL

https://img.dynamicdocumentapi.com/l/{link_id}/{name}.{ext}?{param}={value}&exp={unix}&sig={signature}
  • name: any name of 1 to 80 characters from A–Z, a–z, 0–9, - and _. It becomes the file name of the download (Content-Disposition: inline; filename="{name}.{ext}"), and it's part of the signature.
  • ext: one of the link's formats.
  • Parameters: the values for allowed_params, for example title.text=Summer%20Sale. Parameters left out take the link's defaults.
  • exp (optional): Unix time in seconds after which this URL answers 410 link_expired.
  • sig: the signature. Open links don't need one.

The query string may be at most 8 KB, and each parameter may appear only once.

How parameters reach the template

Parameters are turned into data the same way as overrides in a render request:

  • Canvas templates use element.property names, such as title.text or hero.src, exactly as they appear in the template's dynamic properties.
  • Code and Markdown templates receive dotted names as nested data: customer.name=Ada becomes {"customer": {"name": "Ada"}}, available as {{ customer.name }}.

Values from the URL are always strings. Use defaults for values of other types.

Sign a URL

The signature is an HMAC-SHA256 of the request line, keyed with the link secret:

sig = base64url( HMAC_SHA256( secret,
        "GET\n/l/{link_id}/{name}.{ext}\n" + canonical_query ) )
  • secret is the base64url-decoded link secret.
  • canonical_query contains every parameter except sig, including exp, sorted by name, each name and value percent-encoded per RFC 3986 (unreserved characters A–Z a–z 0–9 - . _ ~ stay as they are, a space becomes %20), joined as name=value with &.
  • base64url is the URL-safe alphabet without = padding.

Sign on your server, so the secret never reaches a browser:

import { createHmac } from "node:crypto";

const enc = (value: string) =>
  encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);

export function signedUrl(linkId: string, secret: string, name: string, ext: string, params: Record<string, string>) {
  const query = Object.keys(params)
    .sort()
    .map((key) => `${enc(key)}=${enc(params[key])}`)
    .join("&");
  const key = Buffer.from(secret, "base64url");
  const sig = createHmac("sha256", key).update(`GET\n/l/${linkId}/${name}.${ext}\n${query}`).digest("base64url");
  return `https://img.dynamicdocumentapi.com/l/${linkId}/${name}.${ext}?${query}${query ? "&" : ""}sig=${sig}`;
}

signedUrl("lnk_01J9ZM1X3F7R8K2C4V6B8N0P2Q", process.env.LINK_SECRET!, "banner", "png", {
  "title.text": "Summer Sale",
  exp: String(Math.floor(Date.now() / 1000) + 7 * 86400),
});
import base64, hashlib, hmac, time
from urllib.parse import quote

def signed_url(link_id: str, secret: str, name: str, ext: str, params: dict[str, str]) -> str:
    query = "&".join(f"{quote(k, safe='-._~')}={quote(v, safe='-._~')}" for k, v in sorted(params.items()))
    key = base64.urlsafe_b64decode(secret + "=" * (-len(secret) % 4))
    message = f"GET\n/l/{link_id}/{name}.{ext}\n{query}".encode()
    sig = base64.urlsafe_b64encode(hmac.new(key, message, hashlib.sha256).digest()).rstrip(b"=").decode()
    return f"https://img.dynamicdocumentapi.com/l/{link_id}/{name}.{ext}?{query}{'&' if query else ''}sig={sig}"

signed_url("lnk_01J9ZM1X3F7R8K2C4V6B8N0P2Q", LINK_SECRET, "banner", "png",
           {"title.text": "Summer Sale", "exp": str(int(time.time()) + 7 * 86400)})

Without code, POST /v1/signed-links/{id}/sign with {"params": {...}, "format": "png", "name": "banner", "expires_at": 1789661194} returns a signed url. The dashboard's Build a URL action does the same.

Some tools can't compute an HMAC, for example email builders that only insert merge fields into a URL. For them, create a link with "access": "open": it accepts unsigned URLs, and ignores sig if one is present.

An open link needs a param_limits entry for every allowed parameter, a quota_total and a rate_limit_per_ip_per_hour, because anyone who sees the URL can change its parameters within those limits. Keep the parameters to what the design can safely show, set tight max_length values, and prefer signed links wherever you can sign.

Checks and errors

Every request runs these checks in this order. The first one that fails decides the answer:

#CheckAnswer
1The link exists, is enabled and not deleted, the workspace is active, and the extension is one of the link's formats404 not_found
2The link's expires_at and the URL's exp are in the future410 link_expired
3Signed links: sig matches. Open links: every parameter is allowed and has a limit403 invalid_signature / 400 validation_error
4Every parameter is in allowed_params and within its max_length400 validation_error
5If allowed_referrers is set, a Referer header names one of those sites. Requests without a Referer, such as images in email clients, pass.403 referrer_not_allowed
6The client IP address is within the hourly rate limit429 rate_limited with Retry-After
7Your plan includes signed links402 plan_feature_unavailable

When rendering, the link's quota and your workspace's renders apply: 429 quota_exceeded once the link has used quota_total renders, and 402 render_limit_reached when the workspace has no renders left. With a fallback_image_asset_id, the link returns that image (200, Cache-Control: public, max-age=60, header X-Fallback-Reason: render_limit_reached) instead of the 402, so emails and pages keep showing something. A template error answers 422 like a render request.

Errors are problem details in JSON (application/problem+json) and are never cached.

Caching

A result is cached per link, live template version, format and parameter values (after defaults are applied). The name in the URL, exp and sig are not part of the cache key.

SituationWhat happensBilled
First request for these parametersThe template's live version is rendered and the result is cached1 render
The same parameters againThe cached file is returned; no render is created0
You publish a new versionThe cache key changes, the next request renders the new version1 render
You change the link's defaultsThe effective parameters change, so the next request renders1 render

Responses carry:

HeaderValue
Content-Typeimage/png, image/jpeg, image/webp or application/pdf
Cache-Controlpublic, max-age=…: cache_ttl_seconds (1 hour by default), at most one day, and never beyond the URL's exp or the link's expires_at
ETagIdentifies the cached result; send it back as If-None-Match to get 304 Not Modified
X-Billed-Renders1 when this request rendered, 0 for cache hits, 304s and errors
X-Render-IdThe render a cache miss created. It appears in your render log with source signed_link and reference signed:{link_id}.
Access-Control-Allow-Origin*, so pages on any site can load the file with fetch

HEAD requests run the same checks and answer with the same headers, but never render or bill. Browsers and our edge network cache files as Cache-Control allows. Disabling or deleting a link stops new requests at once; copies already cached in browsers expire on their own.

Quotas and billing

Each cache miss is one billed render, whatever the format or number of pages; cache hits are free. They count towards your plan's renders like any other render and appear in usage with the source signed_link.

quota_total caps the billed renders of one link. It counts cache misses only, so a popular image keeps being served from the cache after the quota is reached, while URLs with new parameters answer 429 quota_exceeded. quota_used on the link shows the count.

The template's default e-invoice applies to .pdf links as it does to render requests (the invoice data comes from the link's defaults); images are never e-invoices.

On this page