# 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-a-link]

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

```bash
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](#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 [#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 [#how-parameters-reach-the-template]

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

* **[Canvas templates](/docs/image-options#image-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 [#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:

```ts
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),
});
```

```python
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 [#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 [#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](/docs/errors) in JSON (`application/problem+json`) and are never cached.

## Caching [#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, `304`s 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 [#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](/docs/e-invoicing#make-a-template-an-e-invoice-by-default) applies to `.pdf` links as it does to render requests (the invoice data comes from the link's `defaults`); images are never e-invoices.
