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.
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 a link
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.
| Field | Description |
|---|---|
template_id | The template to render. It must have a published version; the link always renders the live one. Can't be changed later. |
formats | Extensions the URL may use: png, jpeg (also as .jpg), webp, pdf |
allowed_params | The parameters a URL may set. Anything else is rejected. |
defaults | Values used when the URL leaves a parameter out |
param_limits | Per parameter, {"max_length": n}. Required for every allowed parameter of an open link. |
access | signed (default) or open, see Open links |
expires_at | After this time every URL of the link answers 410 link_expired |
quota_total | Maximum number of billed renders (cache misses) for the link |
rate_limit_per_ip_per_hour | Requests per client IP address and hour. Unlimited by default for signed links, 120 for open links. |
allowed_referrers | Sites that may embed the link, as exact hosts (example.com) or *.example.com for subdomains |
cache_ttl_seconds | How long a result stays cached. By default until the template is published again, at most 30 days. |
fallback_image_asset_id | An image asset served instead of an error when your workspace has no renders left |
enabled | Set 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 fromA–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 exampletitle.text=Summer%20Sale. Parameters left out take the link's defaults. exp(optional): Unix time in seconds after which this URL answers410 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.propertynames, such astitle.textorhero.src, exactly as they appear in the template's dynamic properties. - Code and Markdown templates receive dotted names as nested data:
customer.name=Adabecomes{"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 ) )secretis the base64url-decoded link secret.canonical_querycontains every parameter exceptsig, includingexp, sorted by name, each name and value percent-encoded per RFC 3986 (unreserved charactersA–Z a–z 0–9 - . _ ~stay as they are, a space becomes%20), joined asname=valuewith&.base64urlis 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.
Open links
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:
| # | Check | Answer |
|---|---|---|
| 1 | The link exists, is enabled and not deleted, the workspace is active, and the extension is one of the link's formats | 404 not_found |
| 2 | The link's expires_at and the URL's exp are in the future | 410 link_expired |
| 3 | Signed links: sig matches. Open links: every parameter is allowed and has a limit | 403 invalid_signature / 400 validation_error |
| 4 | Every parameter is in allowed_params and within its max_length | 400 validation_error |
| 5 | If 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 |
| 6 | The client IP address is within the hourly rate limit | 429 rate_limited with Retry-After |
| 7 | Your plan includes signed links | 402 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.
| Situation | What happens | Billed |
|---|---|---|
| First request for these parameters | The template's live version is rendered and the result is cached | 1 render |
| The same parameters again | The cached file is returned; no render is created | 0 |
| You publish a new version | The cache key changes, the next request renders the new version | 1 render |
You change the link's defaults | The effective parameters change, so the next request renders | 1 render |
Responses carry:
| Header | Value |
|---|---|
Content-Type | image/png, image/jpeg, image/webp or application/pdf |
Cache-Control | public, 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 |
ETag | Identifies the cached result; send it back as If-None-Match to get 304 Not Modified |
X-Billed-Renders | 1 when this request rendered, 0 for cache hits, 304s and errors |
X-Render-Id | The 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.