Picsha AI

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:

  1. Ask the API: POST /assets/{id}/sign-delivery returns the signed URL. Simple, one HTTP call per URL. See the API Reference.
  2. 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:

HostPath formExample
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

  1. Parse the query string as application/x-www-form-urlencoded: percent-decode every key and value, and treat + as a space.
  2. Drop the sig parameter if present.
  3. Sort the remaining keys in ascending byte order (plain ASCII sort, the default string sort in every language).
  4. Emit key=value for each key using the decoded value, joined with &. Do not re-encode.
  5. Keep empty values: wm_text= stays as wm_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.

PathQuerySignature
/v1/assets/as_9f2c1b7e/renderw=800&fit=cover&mimi=golden+hour&exp=1790000000c80a249a5d5e17b4
/seo/as_9f2c1b7e/summer-sale.webp(none)b39bfa6ff3ee43fc
/seo/as_9f2c1b7e/summer-sale.webpfit=cover&w=6009b21ba44f234ea8c
/render/as_9f2c1b7ew=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.