# Signed image URLs: generate images from a URL, without an API call

> How signed image URLs work: an HMAC over the path and sorted query, rendered on first request, cached after. Code for Node, PHP and Python, and limits.

Published 2026-11-06 · https://shareglint.com/en/guides/signed-image-urls

A **signed image URL** lets you generate an image without calling the API: you build the URL yourself — template, values, and a signature made with your signing secret — and put it wherever an image URL goes: an `og:image` tag, an email, a CMS field, a spreadsheet cell. The first request renders the image and redirects to the file; every later request with the same values is a redirect to that file, with no rendering and no quota. This page explains the format, the signature, code for three languages, and the rules that come with it.

## When to use one

The API (`POST /v1/images`) and signed URLs make the same images. The difference is who makes the request:

| | `POST /v1/images` | Signed URL |
|---|---|---|
| Who calls | your server, with an API key | whoever fetches the URL: a crawler, a mail client, a browser |
| When it renders | when you call | on the first fetch |
| What you need | an HTTP request and a key | an HMAC function and the signing secret |
| Good for | apps, batches, webhooks | static sites, themes, emails, no-code, spreadsheets |

Use a signed URL where you cannot keep an API key or send a POST: a Hugo or Jekyll template, a Shopify or Ghost theme, a mailing tool's merge field, a no-code builder. Use the API when you want the file in hand before anyone sees it, or need batches.

## The format

```text
https://shareglint.com/i/{account_id}/{template}.{ext}?{field}={value}&…&s={signature}
```

- **`account_id`** — your account's `acc_…` id. It is public, not a credential.
- **`template`** — a built-in template slug or one of yours.
- **`ext`** — the format: `png`, `jpg`, `webp`, `pdf`, or `gif` and `mp4` for animated templates. The extension is part of what is signed, so a signature for `.png` does not open `.jpg`.
- **Query parameters** — the template's fields, plus optional `locale` and `size` (one of og 1200×630, square 1080×1080, portrait 1080×1350, story 1080×1920, pinterest 1000×1500, youtube 1280×720).
- **`s`** — the signature.

## The signature

1. Take every query parameter **except `s`**.
2. Sort them by name, in byte order.
3. Percent-encode each name and value per [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-2.3) — everything except `A–Z a–z 0–9 - _ . ~`, so a space is `%20`, not `+` — and join them as `name=value` with `&`.
4. The canonical string is the path, `?`, and the joined parameters (the `?` stays even with no parameters).
5. `s` is the first **32** lowercase hex characters of `HMAC-SHA256(canonical string, signing secret)` ([HMAC, RFC 2104](https://www.rfc-editor.org/rfc/rfc2104)).

Test your implementation against this vector before publishing anything. With the secret `sign_example`, the path `/i/acc_01k8example0000000000000000/blog-og.png` and the parameters `title=Hello, world!`, `author=Ann & Bob`, `locale=en`:

```text
canonical  /i/acc_01k8example0000000000000000/blog-og.png?author=Ann%20%26%20Bob&locale=en&title=Hello%2C%20world%21
s          cdc6c6ad85a071f083b5f1374b0a6d98
```

The published URL may list parameters in any order and encode spaces as `+` or `%20`: the server decodes it and rebuilds the canonical string itself. Values are signed exactly as sent — they are not trimmed.

## Code

**Node.js:**

```javascript
import { createHmac } from 'node:crypto';

// RFC 3986: encodeURIComponent leaves ! ' ( ) * alone.
const enc = (v) => encodeURIComponent(String(v))
  .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

export function signedImageUrl(account, secret, template, fields, ext = 'png') {
  const path = `/i/${account}/${template}.${ext}`;
  const query = Object.keys(fields).sort()
    .map((name) => `${enc(name)}=${enc(fields[name])}`)
    .join('&');
  const s = createHmac('sha256', secret).update(`${path}?${query}`).digest('hex').slice(0, 32);
  return `https://shareglint.com${path}?${query}&s=${s}`;
}
```

**PHP:**

```php
function signed_image_url(string $account, string $secret, string $template, array $fields, string $ext = 'png'): string
{
    $path = "/i/{$account}/{$template}.{$ext}";
    ksort($fields, SORT_STRING);
    $query = implode('&', array_map(
        fn ($name, $value) => rawurlencode((string) $name).'='.rawurlencode((string) $value),
        array_keys($fields), $fields,
    ));
    $s = substr(hash_hmac('sha256', $path.'?'.$query, $secret), 0, 32);

    return 'https://shareglint.com'.$path.'?'.$query.'&s='.$s;
}
```

**Python:**

```python
import hashlib, hmac
from urllib.parse import quote

def signed_image_url(account, secret, template, fields, ext="png"):
    path = f"/i/{account}/{template}.{ext}"
    query = "&".join(
        f"{quote(str(k), safe='')}={quote(str(v), safe='')}" for k, v in sorted(fields.items())
    )
    s = hmac.new(secret.encode(), f"{path}?{query}".encode(), hashlib.sha256).hexdigest()[:32]
    return f"https://shareglint.com{path}?{query}&s={s}"
```

All three give the vector above. Google Sheets has its own version in Apps Script on the [Google Sheets page](https://shareglint.com/en/integrations/google-sheets), and n8n's Crypto node (HMAC, SHA256, hex, first 32 characters) signs without code. An AI assistant connected to the [MCP server](https://shareglint.com/en/mcp-server) can build signed URLs for you with its `create_signed_url` tool.

## What happens on a request

- **First request** for a template and set of values: the image renders (about a second for a still image), one render is counted, and the answer is a `302` redirect to the file.
- **Every later request** with the same values: a `302` to the existing file — no render, no quota.
- The redirect may be cached for 1 hour(s), the file itself for a year, so crawlers come back through the signed URL now and then — which is what makes per-platform view counts possible in the dashboard's analytics.

Because the URL renders on demand, it never "expires" the way a stored file can: if the file is gone after your plan's retention period, the next request simply renders it again.

## Rules and limits

- **Unknown fields are an error.** A field the template does not declare returns `400`, so a typo cannot quietly ship an image without its title. (`POST /v1/images` is more lenient.)
- **`size` is reserved** for the size preset; a template field named `size` cannot be set through a signed URL.
- **Errors** are short plain-text bodies: `400` unknown field or locale, `402` quota used up, `403` missing or wrong signature, `404` unknown account or template, `429` rate limit, `503` render still running — retry after `Retry-After`.
- **Rate limits:** 120 correctly signed requests a minute per account (requests with a bad signature never count against yours) and 600 a minute per IP address.
- **The secret.** The signing secret is separate from API keys: a leaked secret gives no API access, but it lets someone render on your quota. Rotating it (`POST /v1/account/signing-secret`) breaks every URL signed with the old secret at once, including published ones.
- **Never sign in the browser.** Anything in client-side JavaScript is public; sign on a server, at build time or in a serverless function.

## Signed URLs and auto OG URLs

There is a second kind of image URL: **auto OG** (`/u/{account}.png?url=…`), which reads a page's title, description and image and renders a card for it. It is not signed; instead it only works for domains you allow in the dashboard. Use auto OG when the data is already on the page (one tag in a theme covers every page), and signed URLs when you choose the values — a different headline, an email's recipient name, a template other than the default card. The [OG images without code](https://shareglint.com/en/og-images-without-code) page covers auto OG.

## Cost

One render per distinct template, set of values, format and size; repeat requests are free. Signed URLs work on every plan, the Free plan included (100 renders a month, with a watermark); Starter is $9 a month for 2,000 renders ([pricing](https://shareglint.com/en/pricing)). The full reference, with the same vector, is in the [docs](https://shareglint.com/en/docs#signed).

## FAQ

### What is a signed image URL?

An image address that carries the template's values in its query string and a signature made with your secret. Our server checks the signature, renders the image on the first request and redirects to the file; nobody can change a value without breaking the signature.

### Why not just put the values in the URL without a signature?

Then anyone could render anything on your quota by editing the URL — any text, in your design, on your account. The signature ties the URL to exactly the values you chose.

### Does a signed URL use quota on every view?

No. The first request renders the image and uses one render; every later request for the same template and values is a redirect to the existing file, with no rendering and no quota.

### Can I sign URLs in the browser?

No. Signing needs the secret, and anything in browser JavaScript is public. Sign on a server, in a build step, in a serverless function, or in a no-code tool's code step.
