Signed Delivery URLs
Some delivery URLs must carry a signature before the CDN will serve them:
- Generative renders (
mimi,mimi_bg,gen_rem,gen_fill,gen_mask,bg_rem=true) always need one when embedded in a page, because the browser cannot send your API key. - Strict Transformations (organization-wide, or per asset via
requiresSignature: true) require one on every delivery URL, including plain resizes.
You can get that signature two ways:
- Ask the API:
POST /assets/{id}/sign-deliveryreturns the signed URL. Simple, one HTTP call per URL. See the API Reference. - Compute it yourself: run the scheme below on your own server with your API key. No API call, no latency, and it works offline. This is how Cloudinary and imgix customers sign URLs, and it is the recommended path for any application that renders galleries or lists.
This page is the contract for option 2. It is a supported, versioned feature of the platform: the algorithm described here will not change without a new version marker (see Versioning).
The scheme (signature v1)
A signature is the first 16 hexadecimal characters of an HMAC-SHA256 over a canonical string, keyed with one of your API keys.
signature = hex( HMAC-SHA256( key = apiKey, message = canonicalString ) )[0:16]
1. Choose the path
Sign the path exactly as it will be requested, on the host you will serve from:
| Host | Path form | Example |
|---|---|---|
cdn.picsha.ai | /render/{assetId} | /render/as_9f2c1b7e |
cdn.picsha.ai | /seo/{assetId}/{seoFileName} | /seo/as_9f2c1b7e/summer-sale.webp |
api.picsha.ai | /v1/assets/{assetId}/render | /v1/assets/as_9f2c1b7e/render |
api.picsha.ai | /v1/seo/{assetId}/{seoFileName} | /v1/seo/as_9f2c1b7e/summer-sale.webp |
The path is part of the signed message, so a signature for the CDN form is not valid for the API form and vice versa. Serve from the CDN directly whenever you can; the API form simply redirects there.
2. Canonicalize the query
- Parse the query string as
application/x-www-form-urlencoded: percent-decode every key and value, and treat+as a space. - Drop the
sigparameter if present. - Sort the remaining keys in ascending byte order (plain ASCII sort, the default string sort in every language).
- Emit
key=valuefor each key using the decoded value, joined with&. Do not re-encode. - Keep empty values:
wm_text=stays aswm_text=.
Do not repeat a key. If a key appears more than once, only its first value is signed, and the render will use the first value too.
3. Build the canonical string
canonicalString = path (when the canonical query is empty)
canonicalString = path + "?" + canonicalQuery
4. Sign and append
Compute the HMAC, take the first 16 hex characters (lowercase), and append sig=<signature> to the URL you serve. The other parameters may stay in whatever order and encoding you like, because the verifier re-canonicalizes before checking. For CDN cache efficiency, keep the order stable between page loads.
Worked example
Key: sk_live_docs_example_do_not_use
Path: /render/as_9f2c1b7e
Query as you would write it: w=800&fit=cover&mimi=golden+hour&exp=1790000000
Canonical string:
/render/as_9f2c1b7e?exp=1790000000&fit=cover&mimi=golden hour&w=800
Signature: 6073ece47d8e2a71
Final URL:
https://cdn.picsha.ai/render/as_9f2c1b7e?w=800&fit=cover&mimi=golden+hour&exp=1790000000&sig=6073ece47d8e2a71
The same query with the parameters in a different order, mimi percent-encoded as golden%20hour, and a stale sig still in the string, produces the identical signature, because canonicalization removes every one of those differences.
More test vectors
Use these to check an implementation. All use the key above.
| Path | Query | Signature |
|---|---|---|
/v1/assets/as_9f2c1b7e/render | w=800&fit=cover&mimi=golden+hour&exp=1790000000 | c80a249a5d5e17b4 |
/seo/as_9f2c1b7e/summer-sale.webp | (none) | b39bfa6ff3ee43fc |
/seo/as_9f2c1b7e/summer-sale.webp | fit=cover&w=600 | 9b21ba44f234ea8c |
/render/as_9f2c1b7e | w=400&bg_rem=true&wm_text= | 8a2995ab69b2dee3 |
Reference implementations
Node.js
import { createHmac } from 'node:crypto';
export function signDeliveryUrl(apiKey: string, path: string, query: Record<string, string | number | boolean>): string {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(query)) params.set(k, String(v));
params.delete('sig');
const canonical = Array.from(params.keys()).sort()
.map((k) => `${k}=${params.get(k)}`)
.join('&');
const message = canonical ? `${path}?${canonical}` : path;
const sig = createHmac('sha256', apiKey).update(message).digest('hex').slice(0, 16);
params.set('sig', sig);
return `${path}?${params.toString()}`;
}
// signDeliveryUrl(key, '/render/as_9f2c1b7e', { w: 800, fit: 'cover', mimi: 'golden hour', exp: 1790000000 })
// -> /render/as_9f2c1b7e?w=800&fit=cover&mimi=golden+hour&exp=1790000000&sig=6073ece47d8e2a71
Python
import hmac, hashlib
from urllib.parse import parse_qsl, urlencode
def sign_delivery_url(api_key: str, path: str, query: dict) -> str:
params = {k: str(v) for k, v in query.items() if k != "sig"}
canonical = "&".join(f"{k}={params[k]}" for k in sorted(params))
message = f"{path}?{canonical}" if canonical else path
sig = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest()[:16]
params["sig"] = sig
return f"{path}?{urlencode(params)}"
# sign_delivery_url(key, "/render/as_9f2c1b7e", {"w": 800, "fit": "cover", "mimi": "golden hour", "exp": 1790000000})
# -> /render/as_9f2c1b7e?w=800&fit=cover&mimi=golden+hour&exp=1790000000&sig=6073ece47d8e2a71
If you start from an already-encoded query string rather than a dict, decode it first with parse_qsl(qs, keep_blank_values=True) and keep the first value of each key.
Expiry
Add exp (Unix seconds) to the query before signing to make a URL time-limited. Because exp sits inside the signed message, it cannot be removed or extended without breaking the signature. Both the API and the CDN edge reject the URL once the time has passed, whichever host you serve from.
A useful pattern for galleries is to round the expiry up to the end of a window (for example the next full hour plus one). Every URL minted inside the window is then identical, so browsers and the CDN keep serving cached responses instead of treating each page view as a new URL.
Which key to use
Sign with any active API key of the organization that owns the asset. The verifier tries every active key, so it does not matter which one you pick. Assets in a personal account (no organization) verify against that account's personal keys.
Rotating keys: a URL signed with a key stops validating the moment that key is deleted. When you rotate, create the new key first, switch your signer to it, and delete the old key only after the longest exp you issued with it has passed.
Never sign in the browser. The key is a full-privilege secret and there is no publishable key. Sign on your server and send the finished URL to the client.
Versioning
This page describes signature v1, and a URL with no version marker is a v1 URL. If the scheme ever changes, the new scheme will be selected by an explicit sv query parameter (for example sv=2) and v1 will keep validating for as long as it is documented here. Do not use sv as a parameter name for anything else.
The platform's own test suite pins the vectors on this page, so a change that breaks them cannot ship unnoticed.