Shareglint

Shareglint documentation

Send JSON, get back a finished PNG URL. No browser on your side.

The machine-readable contract: /openapi.json (OpenAPI 3.1 — import into Postman, generate clients).

Your first render

You need a key: create an account (no card, 100 images a month) — the key appears right after signup. Three starter templates come with the account, so the request below works with no configuration at all.

?wait=true says you want to wait for the image rather than poll for the result.

curl -X POST "https://shareglint.com/v1/images?wait=true" \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template": "blog-og",
    "locale": "pl",
    "modifications": {
      "title": "Ten things that ruin your sleep",
      "author": "Anna Kowalska",
      "site": "yourblog.com",
      "accent": "#f97316"
    }
  }'
$response = Http::withToken('sk_live_TWOJ_KLUCZ')
    ->post('https://shareglint.com/v1/images?wait=true', [
        'template' => 'blog-og',
        'locale' => 'pl',
        'modifications' => [
            'title' => 'Ten things that ruin your sleep',
            'author' => 'Anna Kowalska',
            'site' => 'yourblog.com',
        ],
    ]);

$url = $response->json('url');
const res = await fetch('https://shareglint.com/v1/images?wait=true', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer sk_live_TWOJ_KLUCZ',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    template: 'blog-og',
    locale: 'pl',
    modifications: {
      title: 'Ten things that ruin your sleep',
      author: 'Anna Kowalska',
      site: 'yourblog.com',
    },
  }),
});

const { url } = await res.json();
import requests

res = requests.post(
    "https://shareglint.com/v1/images?wait=true",
    headers={"Authorization": "Bearer sk_live_TWOJ_KLUCZ"},
    json={
        "template": "blog-og",
        "locale": "pl",
        "modifications": {
            "title": "Ten things that ruin your sleep",
            "author": "Anna Kowalska",
            "site": "yourblog.com",
        },
    },
)

url = res.json()["url"]

Authentication

The key goes in the Authorization: Bearer sk_live_… header. You issue and revoke keys through /v1/keys or in the dashboard.

We show the full key exactly once, when it is issued — the database holds only its digest. A lost key cannot be recovered: issue a new one and revoke the old.

The last active key cannot be revoked. That is deliberate: without a working key you could not issue another one, and you would lock yourself out.

Async and sync

ModeResponseWhen
POST /v1/images202 + idThe default. Collect the result from GET /v1/images/{id} or by webhook.
POST /v1/images?wait=true200 + gotowy urlWhen you would rather not poll. A render usually takes about a second.

Synchronous mode has its own resources, separate from the async queue — a flood of background jobs cannot starve a client waiting on a connection. If we still run out of time, you get a 503 with the code sync_overloaded. The job is not lost: the body carries an id, so you can poll for the result exactly as in async mode.

Output formats

Add format to the request body: png (the default), jpeg, webp or pdf. For jpeg and webp, quality (1–100, default 85) sets the compression. For every raster format, scale picks the pixel density: 2 (default) gives 2400×1260 for a 1200×630 template, 1 gives exactly 1200×630 — a smaller file that still meets every social network's og:image size.

{ "template": "blog-og", "format": "jpeg", "quality": 80, "scale": 1, "modifications": { "title": "…" } }

pdf is a single page exactly the template size in CSS pixels (1200×630 for the built-in templates), with no margins and backgrounds printed. Text stays vector, so it is sharp at any zoom and selectable. quality and scale do not apply to it.

The format is part of the image's identity: the same values as PNG and as JPEG are two files and two renders. A parameter that does not change the file — quality for PNG, scale for PDF — never causes a second render. The response and GET /v1/images/{id} carry format, and the file URL ends in .png, .jpg, .webp or .pdf. On the Free plan the watermark is in every format, PDF included.

Idempotency

Add an Idempotency-Key header (any UUID). A repeated request with the same key and the same body returns the same response, with no second render and no second deduction from your quota.

The same key with a different body is a 422 — almost always a bug in the integration rather than an intention.

Separately from that, an identical render never costs twice anyway: the same template with the same values returns the existing image and uses no quota.

Errors and limits

The error field is a stable machine-readable code — build your switch on it. The message field is for humans and follows the Accept-Language header (we support en, pl, de, es, fr).

StatuserrorMeaning
401unauthorizedMissing key, or a key that is invalid or revoked.
402quota_exceededMonthly quota exhausted.
404—The resource does not exist on this account. Somebody else's resources are indistinguishable from nonexistent ones.
422idempotency_key_reuse, last_keyValidation error or a conflict.
429—Too many requests per minute.
503sync_overloadedSync mode ran out of time. The job is alive — poll it by id.

How long images live

Finished files do not sit here forever, and we say so plainly, because the URL from our response usually goes straight into somebody's og:image.

PlanImage available for
Free30 days from generation
Starter, Pro, Scale365 days from generation

After that both the file and the history entry are gone. It also means the same render ordered again no longer hits the cache and counts against your quota a second time. If an image must outlive our retention, download it right after generating — the URL is a plain PNG file.

Your own templates

You send your own HTML and CSS. Fields go in as {{ nazwa }} and are declared in fields. An undeclared field is an error on save, not a blank spot in the finished image.

{
    "name": "Event card",
    "fields": [
        "headline",
        "place",
        "photo"
    ],
    "images": [
        "photo"
    ],
    "css": "body{background:#111827;color:#f9fafb;padding:72px;font-family:Roboto}",
    "html": "<div class=\"fitbox\"><h1 class=\"fit\" data-autofit>{{ headline }}</h1></div><span>{{ place }}</span>"
}

A few things worth knowing up front:

Preview uses no quota, so iterating on the design costs nothing.

Built-in templates

Every account can render these by slug with no setup — "template": "job-posting" just works. Every field is optional; accent (hex colour) and theme (dark or light) work in all of them. Previews and examples for each one are in the template gallery.

SlugFields
blog-og title, eyebrow, author, site, date, reading_time, photo, accent, theme
site-card title, description, site, url, favicon, image, accent, theme
job-posting title, company, location, salary, type, eyebrow, cta, site, logo, accent, theme
event name, date, venue, eyebrow, site, image, accent, theme
podcast-episode title, show, episode, guest, duration, site, cover, accent, theme
changelog version, title, product, highlight_1, highlight_2, highlight_3, date, site, logo, accent, theme
docs-page title, description, section, product, site, logo, accent, theme
real-estate price, address, city, beds, baths, area, badge, site, photo, logo, accent, theme
product-og name, tagline, price, badge, site, logo, screenshot, accent, theme
product-sale name, brand, price, old_price, discount, cta, site, photo, accent, theme
course-lesson title, course, lesson, instructor, duration, progress, site, photo, accent, theme
recipe title, category, time, servings, difficulty, author, site, photo, accent, theme
video-thumbnail title, highlight, badge, channel, duration, image, avatar, accent, theme
newsletter-issue title, newsletter, issue, date, excerpt, author, site, accent, theme
quote-og quote, author, role, site, photo, accent, theme
case-study quote, name, role, company, metric, metric_label, site, photo, logo, accent, theme
github-repo repo, owner, description, stars, forks, language, site, avatar, accent, theme
feature-announcement title, description, eyebrow, product, site, logo, screenshot, accent, theme
profile-card name, headline, bio, handle, location, site, photo, accent, theme
comparison left, right, title, left_note, right_note, accent_b, site, left_logo, right_logo, accent, theme
stat-highlight value, label, source, eyebrow, site, logo, accent, theme

GET /v1/templates/library returns the same list as JSON, with defaults, example values and a preview URL. Built-in templates are read-only; POST /v1/templates/library/{slug}/copy creates an ordinary HTML template on your account from one of them, with the same fields, which you can then change with PATCH /v1/templates/{id}. If the slug is free on your account the copy takes it, so your existing calls switch to your version; otherwise it gets a -copy suffix (or pass your own slug and name). A template on your account always wins over a built-in one with the same slug.

Text auto-fitting

Sooner or later somebody puts a 200-character title into a field designed for 60. Add data-autofit to an element inside a .fitbox container and we shrink the font until the text fits. Set the floor with data-autofit-min.

<div class="fitbox">
  <h1 class="fit" data-autofit data-autofit-min="36">{{ title }}</h1>
</div>

Visual editor

Not writing CSS? Build the template in the visual editor in your dashboard: start from a layout, add text, image and shape layers, drag them into place (with snapping to edges and centres), set fonts, colours, gradients, corner radius and opacity, and press Preview to see the real PNG rendered by Chrome — previews use no quota.

Bind any text layer to a field name and the text you typed becomes sample content and the field's default value; you can also type {{ name }} anywhere inside a text. Image layers either take an image URL per render (an image field) or an uploaded picture. The editor declares fields and images for you.

The editor compiles your layers into ordinary HTML and CSS that go through the same sanitiser and the same render path as hand-written templates, so you call it with "template": "your-slug" like any other template. Uploaded pictures are embedded in the CSS, so together they must fit in about 48 KB — the editor compresses them for you; for photos that change on every image, use an image field.

Editing an editor-made template by hand (in the code editor or with PATCH /v1/templates) switches it to code mode: from then on it is plain HTML and CSS and the visual editor no longer opens it.

Generate a template with AI

Describe the image in a sentence or two, or upload a screenshot of a design you like, and POST /v1/templates/generate returns a ready template: HTML, CSS, fields, sample defaults and a free preview URL. It is a proposal — nothing is saved until you send it to POST /v1/templates unchanged (or after editing). In the dashboard the Generate with AI button fills the code editor for you.

curl https://shareglint.com/v1/templates/generate \
  -H "Authorization: Bearer sk_live_…" \
  -F description="Dark blog card, big white title, author at the bottom, purple accent" \
  -F image=@design.png

The answer goes through exactly the same validation and sanitiser as a template you write yourself: only our fonts, {{ field }} placeholders, .fitbox/data-autofit for long titles, no scripts and no external URLs. Templates are 1200×630.

Paid plans only (Starter 20, Pro 50, Scale 100 generations a day, reset at midnight UTC); Free gets 403 plan_required, the daily limit 429 ai_daily_limit. If the AI provider fails you get 502 ai_failed and nothing is counted. The image is a PNG, JPEG or WebP of up to 4 MB, sent as multipart image; we do not keep your description or image after the request.

Webhooks

Pass webhook_url when requesting a render and we will call you when it finishes — instead of you polling in a loop.

{
    "event": "image.completed",
    "id": "img_01m08s3ez1zcv28h335g9923bw",
    "status": "completed",
    "url": "https://cdn.yourdomain.com/renders/img_01m08s3ez1zcv28h335g9923bw.png",
    "error": null,
    "created_at": "2026-08-17T21:13:54+00:00"
}

Every call carries X-Webhook-Timestamp and X-Webhook-Signature headers. The secret to verify them is in the dashboard or at GET /v1/account; rotate it through POST /v1/account/webhook-secret. We sign both values so an intercepted call cannot be replayed:

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);

if (! hash_equals($expected, $request->header('X-Webhook-Signature'))) {
    abort(400);
}

We retry five times with growing backoff, so a brief outage on your side does not lose the event. A private-network address is rejected immediately with a 422 — rather than accepted, charged against your quota, and then quietly never called.

Signed image URLs (no server code)

A URL you paste straight into og:image — for static sites, Webflow, Shopify, Ghost themes and no-code tools, where you cannot keep an API key or send a POST. Template fields go in the query string, and the s parameter is a signature made with your signing secret. Available on every plan, Free included.

<meta property="og:image" content="https://shareglint.com/i/acc_YOUR_ACCOUNT_ID/blog-og.png?title=Ten%20things%20that%20ruin%20your%20sleep&s=…">

The first request renders the image (about a second), uses one render of your quota exactly like POST /v1/images, and answers with a 302 to the finished file. Every later request for the same template and the same values is a 302 to the existing file — no rendering, no quota. The redirect is cacheable for an hour (the file itself for a year), so crawlers come back through us now and then — and each of those visits shows up in your view analytics.

How to sign

  1. Path: /i/{account_id}/{template}.{ext} — your account id (acc_…, public, not a credential), the template slug and the extension you request. No host. The extension picks the format: .png, .jpg, .webp (quality 85) or .pdf; rasters are 2× the template size. The extension is part of the signed path, so sign exactly the URL you publish — a signature for .png does not open .jpg.
  2. Take every query parameter except s (template fields and the optional locale), sort them by name (byte order), percent-encode the name and the value per RFC 3986 — everything except A–Z a–z 0–9 - _ . ~, so a space is %20 — and join them as name=value with &.
  3. Canonical string = path + ? + the joined parameters. The ? stays even when there are no parameters.
  4. s = the first 32 lowercase hex characters of HMAC-SHA256(canonical, signing_secret) (128 bits — plenty against guessing behind our rate limits, and half the URL length).
  5. Append s to the URL. The URL itself may list parameters in any order and encode spaces as + or %20: we decode it and rebuild the canonical string ourselves. Values are signed exactly as sent — we do not trim them.

Check your implementation against this test vector before you paste anything into og:image:

secret     sign_example
path       /i/acc_01k8example0000000000000000/blog-og.png
params     title=Hello, world!   author=Ann & Bob   locale=en
canonical  /i/acc_01k8example0000000000000000/blog-og.png?author=Ann%20%26%20Bob&locale=en&title=Hello%2C%20world%21
s          cdc6c6ad85a071f083b5f1374b0a6d98

PHP

function signedImageUrl(string $accountId, string $template, array $params, string $secret, string $ext = 'png'): string
{
    $path = "/i/{$accountId}/{$template}.{$ext}"; // png, jpg, webp or pdf

    ksort($params, SORT_STRING);
    $query = implode('&', array_map(
        fn ($name, $value) => rawurlencode((string) $name).'='.rawurlencode((string) $value),
        array_keys($params),
        $params,
    ));

    $s = substr(hash_hmac('sha256', $path.'?'.$query, $secret), 0, 32);

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

echo signedImageUrl('acc_YOUR_ACCOUNT_ID', 'blog-og', [
    'title' => 'Ten things that ruin your sleep',
    'author' => 'Anna Kowalska',
], getenv('SHAREGLINT_SIGNING_SECRET'));

JavaScript (Node)

import { createHmac } from 'node:crypto';

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

export function signedImageUrl(accountId, template, params, secret, ext = 'png') {
  const path = `/i/${accountId}/${template}.${ext}`; // png, jpg, webp or pdf
  const query = Object.keys(params).sort()
    .map((name) => `${enc(name)}=${enc(params[name])}`)
    .join('&');
  const s = createHmac('sha256', secret).update(`${path}?${query}`).digest('hex').slice(0, 32);

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

console.log(signedImageUrl('acc_YOUR_ACCOUNT_ID', 'blog-og', {
  title: 'Ten things that ruin your sleep',
  author: 'Anna Kowalska',
}, process.env.SHAREGLINT_SIGNING_SECRET));

No-code tools

Anything with an HMAC-SHA256 function can sign: a JavaScript code step in Zapier, Pipedream and similar tools (adapt the JavaScript above), n8n's Crypto node (action Hmac, type SHA256, encoding hex — then keep the first 32 characters), or the build step of a static site generator. Build the canonical string exactly as in the steps above. Never sign in browser JavaScript: the secret would be public.

Good to know

Auto OG images (one tag, no code)

Add your domain in the dashboard (or with POST /v1/auto-og/sites), then put one tag in your site template. For each page we fetch the page, read its title, description, site name, icon, og:image and theme-color, and render a card with the site-card template — or a template of your own.

<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url=https%3A%2F%2Fexample.com%2Fblog%2Fhello-world">
<meta name="twitter:card" content="summary_large_image">

Snippets for your platform:

HTML

Any site: put the tag in the <head> of each page, with that page's address URL-encoded after ?url=. Static site generators can fill it in from the page URL variable.

<!-- In the <head> of each page, with that page's own address -->
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url=https%3A%2F%2Fyour-site.com%2Fpricing">
<meta name="twitter:card" content="summary_large_image">

WordPress

Add to functions.php of a child theme or with a code-snippets plugin. If an SEO plugin (Yoast, Rank Math) already prints og:image, turn its image off so the page has only one.

// functions.php of your (child) theme, or a code-snippets plugin
add_action('wp_head', function () {
    global $wp;
    $page = is_singular() ? wp_get_canonical_url() : home_url(user_trailingslashit($wp->request));
    $image = 'https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url=' . rawurlencode($page);

    echo '<meta property="og:image" content="' . esc_attr($image) . '">' . "\n";
    echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
}, 1);

Shopify

Online Store → Themes → Edit code. Replace the og:image lines in snippets/meta-tags.liquid (or in layout/theme.liquid). canonical_url works on products, collections, pages and blog posts.

{%- comment -%} snippets/meta-tags.liquid — replace the og:image lines {%- endcomment -%}
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url={{ canonical_url | url_encode }}">
<meta property="og:image:secure_url" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url={{ canonical_url | url_encode }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">

Ghost

Edit default.hbs of your theme and put the tags above {{ghost_head}}. Code injection in Ghost Admin does not run Handlebars, so it cannot fill in the page address.

{{!-- default.hbs, in <head> ABOVE {{ghost_head}} --}}
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url={{url absolute="true"}}">
<meta name="twitter:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url={{url absolute="true"}}">
<meta name="twitter:card" content="summary_large_image">

Hugo

Put it in the partial that renders your <head>. .Permalink is the absolute address of each page; urlquery encodes it.

{{/* layouts/partials/head.html (or the <head> of baseof.html) */}}
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url={{ .Permalink | urlquery }}">
<meta name="twitter:card" content="summary_large_image">

Webflow

On a CMS template page: Page settings → Custom code → Inside <head> tag, with the Slug field inserted where it says SLUG. Static pages: the same tag with the page's address.

<!-- CMS template page → Page settings → Custom code → Inside <head> tag.
     Replace SLUG with the Slug field (“+ Add field”). -->
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url=https://your-site.com/blog/SLUG">
<meta name="twitter:card" content="summary_large_image">

Wix

Per page: SEO basics → Advanced SEO → Additional tags. Use the address of that page after ?url=.

<!-- Page → SEO basics → Advanced SEO → Additional tags, per page -->
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url=https://your-site.com/about">

Framer

Per page: Page settings → Custom Code → End of <head>, with the address of that page. Clear the page's own social image so there is only one og:image.

<!-- Page settings → Custom Code → End of <head>, per page -->
<meta property="og:image" content="https://shareglint.com/u/acc_YOUR_ACCOUNT_ID.png?url=https://your-site.com/features">
<meta name="twitter:card" content="summary_large_image">

Screenshot API

Send a public URL, get back a PNG, JPEG, WebP or PDF of the page — the viewport or the whole scrollable page, with device presets, dark mode, extra wait, a selector to wait for, selectors to hide and a small ad/cookie-banner filter. Same modes as images: asynchronous by default, ?wait=true to wait (up to 25 s), webhook_url to be called (screenshot.completed / screenshot.failed).

curl -X POST "https://shareglint.com/v1/screenshots?wait=true" \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "full_page": true, "format": "webp", "block_ads": true}'

A viewport screenshot uses 1 render of your quota, a full-page one 2. The same URL with the same options within 24 h returns the existing file for free. A page that fails — a 4xx/5xx status, a timeout, a blocked address — returns status: failed with the reason, and the quota comes back. On the Free plan screenshots carry the same small “Made with” badge as images.

No server? Sign a URL exactly like image URLs, with the path /s/{account_id}.{ext} and the options as query parameters (hide comma-separated), and use it in an <img> or og:image:

<img src="https://shareglint.com/s/acc_YOUR_ACCOUNT_ID.png?url=https%3A%2F%2Fexample.com&device=mobile&s=…">

MCP: use with Claude, ChatGPT, Cursor

The API is also a remote Model Context Protocol server, so an AI assistant can render images, list templates, build signed og:image URLs and check your quota for you. Endpoint (streamable HTTP): https://shareglint.com/mcp. It uses your normal API key in the Authorization: Bearer sk_live_… header — same account, same quota, same limits as /v1.

Tools: render_image, get_image, list_templates, get_template, create_signed_url, get_usage, take_screenshot and generate_template (paid plans). Errors come back as {"error": code, "message": …} with the same codes as the HTTP API.

Claude Code:

claude mcp add --transport http shareglint https://shareglint.com/mcp \
  --header "Authorization: Bearer sk_live_…"

Cursor (~/.cursor/mcp.json), VS Code and other clients that take a URL with headers:

{
  "mcpServers": {
    "shareglint": {
      "url": "https://shareglint.com/mcp",
      "headers": { "Authorization": "Bearer sk_live_…" }
    }
  }
}

From code — the Claude API MCP connector or the OpenAI Responses API pass the key the same way:

// Claude API (MCP connector)
"mcp_servers": [{ "type": "url", "url": "https://shareglint.com/mcp", "name": "images", "authorization_token": "sk_live_…" }]

// OpenAI Responses API
"tools": [{ "type": "mcp", "server_label": "images", "server_url": "https://shareglint.com/mcp", "headers": { "Authorization": "Bearer sk_live_…" } }]

Chat apps whose connector screen offers only an OAuth sign-in cannot send an API key yet; use one of the clients above until we add OAuth.

View analytics

Every image we serve through a URL — a signed /i/… URL (and auto-OG and screenshot URLs) — adds one to a daily counter for the platform that fetched it, worked out from the User-Agent: Facebook, LinkedIn, X, Slack, Discord, WhatsApp, Telegram, iMessage, Google, other bots or a browser. Link previews count as shares — roughly “your link was pasted N times”. See them in the dashboard or fetch them from GET /v1/analytics?days=30 (up to 90 days, UTC).

Reference

The list below is generated from the same spec we serve at /openapi.json.

Endpoint descriptions are in English regardless of the page language: the spec is a machine contract that clients are generated from, so it has one fixed version.

Images

POST /v1/images Request a render

Asynchronous by default: you get a 202 and an `id`, then collect the result from `GET /images/{id}`. With `?wait=true` we hold the connection until the image is ready and return 200 — or 503 `sync_overloaded` if we run out of time. `format`, `quality` and `scale` are part of the cache key: the same fields as PNG and as JPEG are two files and two renders, while a parameter the format ignores (`quality` for PNG) never makes a second one.

ParameterInDescription
wait query Wait for the result instead of polling.
Idempotency-Key header The same `Idempotency-Key` with the same body returns the same response, with no second render. An empty or whitespace-only header counts as no key.

Example request

{
    "template": "blog-og",
    "locale": "en",
    "modifications": {
        "eyebrow": "Health",
        "title": "Ten things that ruin your sleep",
        "author": "Anna Kowalska",
        "site": "yourblog.com",
        "accent": "#f97316",
        "date": "2026-08-17",
        "reading_time": 7
    }
}
ResponseDescription
200Finished (only with `?wait=true`).
202Accepted into the queue.
402Monthly quota exhausted (`quota_exceeded`).
422Validation error (including an unknown `format`, `quality` outside 1–100 or `scale` other than 1 or 2), or the same `Idempotency-Key` with a different body.
503Sync mode ran out of time (`sync_overloaded`). The body carries `id` — nothing is lost.
GET /v1/images Render history

Cursor pagination: pass `before` from the `next_cursor` field. Renders arriving while you page through will not duplicate results.

ParameterInDescription
limit query
before query Cursor from `next_cursor`.
ResponseDescription
200A page of renders, newest first.
GET /v1/images/{id} Render status
ParameterInDescription
id path
ResponseDescription
200The render.
404No such render on this account.
GET /i/{account}/{template}.{ext} Signed image URL (for og:image)

A URL you paste straight into `<meta property="og:image">` — no server code, no API key. Template fields go in the query string, `s` is an HMAC signature made with your `signing_secret`. The extension picks the file format: `.png`, `.jpg` (quality 85), `.webp` (quality 85) or `.pdf`; rasters are 2× the template size.

Signature: build the canonical string `/i/{account}/{template}.{ext}` (the extension you request, e.g. `/i/acc_…/blog-og.jpg`) + `?` + every query parameter except `s`, sorted by name (byte order), each name and value percent-encoded per RFC 3986 (space = `%20`), joined as `name=value` with `&` (the `?` stays even with no parameters). `s` = the first 32 lowercase hex characters of HMAC-SHA256(canonical, signing_secret). The URL itself may use any order and encoding (`+` or `%20`) — we decode it and rebuild the canonical string.

An identical render that already exists is a 302 with no quota used and no rendering. Otherwise we render synchronously, use one render of quota exactly like `POST /images`, and redirect. Redirects carry `Cache-Control: public` for up to 1 hour (never past the file's retention; the file itself is cached for a year). The short redirect lifetime is deliberate: crawlers come back through us, so every served image is counted in `GET /analytics`. Errors are short `text/plain` bodies, never HTML. Limits: 120 correctly signed requests per minute per account, 600 requests per minute per IP.

ParameterInDescription
account path Your account `id` from `GET /account` (`acc_…`). Public, not a credential.
template path Template slug, e.g. `blog-og`.
ext path File format. Part of the signed path: a signature for `.png` does not open `.pdf`.
s query Signature — see above.
locale query Language for text the template adds itself. Signed like any other parameter.
{field} query Any field the template declares (`fields`, `images`, `defaults`; built-in templates: their documented fields plus `accent` and `theme`). An undeclared field is a 400.
ResponseDescription
302Redirect to the finished file in the requested format.
400A field the template does not declare, an unsupported `locale`, or a non-string parameter.
402Monthly quota exhausted (only when a new render was needed).
403Missing, malformed or invalid signature.
404No such account or template. The two are indistinguishable.
429Rate limit. See `Retry-After`.
500The render failed. No quota is used.
503The render did not finish in time. It keeps going — retry after `Retry-After`.

Templates

GET /v1/templates List templates
ResponseDescription
200Templates on this account, both built-in and your own, oldest first. Not paginated. Built-in templates from the library (`GET /templates/library`) work by slug without being listed here; the first render of one adds it to this list as a read-only `engine: blade` template.
POST /v1/templates Create a template

You send your own HTML and CSS. Fields go in as `{{ name }}` and **must be declared** in `fields` — a typo fails with a 422 on save rather than leaving a blank spot in the image.

The HTML is sanitised on save (`<script>`, `<iframe>` and `on*` attributes are dropped), and the CSS loses `@import`, `@font-face` and any `url()` outside `data:`. We store the sanitised version — the `html` field in the response returns it.

Limits: HTML and CSS up to 64 KB each, dimensions from 200 to 4000 px.

Instead of `html`/`css`/`fields` you can send `editor_model` — the layer model the dashboard's visual editor produces (copy it from `GET /templates/{id}`). We validate it, compile it to HTML and CSS, declare the fields ourselves and sanitise the result like any other template.

Example request

{
    "name": "Event card",
    "width": 1200,
    "height": 630,
    "fields": [
        "headline",
        "place",
        "photo"
    ],
    "images": [
        "photo"
    ],
    "css": "body{background:#111827;color:#f9fafb;padding:72px;font-family:Roboto} h1{font-size:88px;font-weight:900}",
    "html": "<div class=\"fitbox\"><h1 class=\"fit\" data-autofit>{{ headline }}</h1></div><img src=\"{{ photo }}\"><span>{{ place }}</span>"
}
ResponseDescription
201Created.
422A field used in the HTML is not declared, `images` goes beyond `fields`, or a size limit was exceeded.
GET /v1/templates/{id} Template details
ParameterInDescription
id path
ResponseDescription
200The template, with `editor_model`.
404No such template on this account.
PATCH /v1/templates/{id} Replace a template

Replaces the whole definition. Built-in templates (`engine: blade`) cannot be edited — you get a 404. Without `editor_model`, a template made in the visual editor switches to code mode (`editor: code`).

ParameterInDescription
id path
ResponseDescription
200Saved.
404No such template, or it is built-in.
DELETE /v1/templates/{id} Delete a template
ParameterInDescription
id path
ResponseDescription
204Deleted.
GET /v1/templates/library Built-in template library

Ready-made templates every account can render by slug, with no setup: `"template": "job-posting"`. A template on your account with the same slug always wins. Each entry lists its `fields` (all optional; `accent` and `theme` work everywhere), `images`, `defaults`, `example` values, a `preview_url` (a real render) and the `gallery_url` page.

ResponseDescription
200The library, in gallery order.
POST /v1/templates/library/{slug}/copy Copy a built-in template into your account

Built-in templates are read-only. This creates an ordinary, editable HTML template (`engine: html`) from one of them, with the same fields — edit it afterwards with `PATCH /templates/{id}`. Fields the built-in reads in CSS (`accent`, `theme`, `accent_b`) are fixed to their default values in the copy. Without `slug`, the copy takes the library slug if it is free on your account — so existing calls switch to your version — and otherwise gets a `-copy` suffix.

ParameterInDescription
slug path Library slug, e.g. `job-posting`.

Example request

{
    "slug": "careers-card",
    "name": "Careers card"
}
ResponseDescription
201The new template.
404No built-in template with this slug.
422`slug` is taken on this account or malformed.
POST /v1/templates/preview Preview without using quota

Renders a saved template (`template`) **or a draft** you have not saved yet (`html`, `css`, `fields`). Creates no history entry and **uses no quota** — so refining a template costs nothing. Its own rate limit: 30 calls per minute.

Example request

{
    "fields": [
        "title"
    ],
    "html": "<h1>{{ title }}</h1>",
    "css": "body{background:#0f172a;color:#fff;font-family:Roboto}",
    "modifications": {
        "title": "Preview"
    }
}
ResponseDescription
200Preview URL.
POST /v1/templates/generate Generate a template with AI

Paid plans only. Designs a 1200×630 template from a text `description` and/or a screenshot of a design (`image`, multipart, PNG/JPEG/WebP up to 4 MB). The answer passes the same validation and sanitiser as `POST /templates`, comes with a free preview, and is **not saved** — send `template` to `POST /templates` unchanged to keep it. Daily limits: Starter 20, Pro 50, Scale 100 (UTC day). Errors: `403 plan_required`, `429 ai_daily_limit`, `502 ai_invalid_output` (with `errors`), `502 ai_failed` (not counted), `503 ai_unavailable`. Own rate limit: 6 calls per minute.

Example request

{
    "description": "Dark blog card, big white title, author at the bottom, purple accent"
}
ResponseDescription
200Proposed template, preview and today's usage.
403Free plan: `plan_required`.
422Neither `description` nor a valid `image`.
429Daily AI limit reached: `ai_daily_limit` with `resets_at`.
502`ai_invalid_output` or `ai_failed`.
503`ai_unavailable`: the feature is not enabled.
GET /v1/fonts Available fonts

Families installed in our renderer. You cannot upload your own — the render page content security policy blocks external addresses.

ResponseDescription
200Available font families.

Account

GET /v1/account/usage Quota usage

`remaining` is what is left of this month's plan quota. `credits` is the balance of purchased top-up packs (valid 12 months, used only after the monthly quota runs out). Annual plans renew the quota monthly too.

ResponseDescription
200The current period.
GET /v1/account Account details

Includes `webhook_secret` — the same one we sign the `X-Webhook-Signature` header with — and `signing_secret`, which you sign image URLs (`GET /i/{account}/{template}.{ext}`) with. Unlike an API key both are symmetric: both sides need the same value, so they can be read back at any time.

ResponseDescription
200The account.
POST /v1/account/webhook-secret Rotate the webhook secret

Rotation takes effect **immediately, with no grace period**: the old secret stops being valid at once, so until you paste the new one on your side you will reject our calls. We retry five times with growing backoff, so the event is not lost. This route deliberately ignores `Idempotency-Key` — the response contains a plaintext secret.

ResponseDescription
200The new secret.
POST /v1/account/signing-secret Rotate the image URL signing secret

Every signed image URL issued so far stops working **immediately** — including the ones already published in `og:image` tags. That is the point: a leaked secret lets anyone render on your quota. Redirects already cached by crawlers and CDNs may live up to 1 hour. This route deliberately ignores `Idempotency-Key` — the response contains a plaintext secret.

ResponseDescription
200The new secret.
GET /v1/keys List API keys

We return the prefix only — the full key cannot be recovered, the database holds just its sha256.

ResponseDescription
200Keys on this account.
POST /v1/keys Issue an API key

The response carries `secret`, and this is the **only** time you will see it. This route deliberately ignores `Idempotency-Key`: storing the response would mean keeping a plaintext credential in the database.

ResponseDescription
201The new key.
DELETE /v1/keys/{id} Revoke a key

The last active key cannot be revoked (422 `last_key`) — without a working key you could not issue a new one and would lock yourself out.

ParameterInDescription
id path
ResponseDescription
204Revoked.
422This is the last active key (`last_key`).

Analytics

GET /v1/analytics View analytics

Daily counts of images we served through a URL (`GET /i/…`; auto-OG and screenshot URLs count too), by the platform that fetched them, worked out from the User-Agent: `facebook`, `linkedin`, `x`, `slack`, `discord`, `whatsapp`, `telegram`, `imessage`, `google`, `other-bot`, `browser`. Link previews (Facebook, LinkedIn, X, Slack, Discord, WhatsApp, Telegram, iMessage) are `share: true` and add up to `shares` — roughly "your link was pasted N times". We store no IP address and no User-Agent, only the counters. Files fetched straight from storage (`url` of a render) are not counted; the `/i/` redirect is cached for 1 hour so crawlers come back through us. Days without views are present with zeros. Times are UTC.

ParameterInDescription
days query How many days back, today included.
ResponseDescription
200Counters for the period.
422`days` outside 1–90.

Auto OG

GET /v1/auto-og/sites List allowed domains

Domains whose pages get auto OG images (`GET /u/{account}.{ext}?url=…`). Each entry covers the domain, `www.` and every subdomain. `url_prefix` is your image URL up to `?url=` — append the URL-encoded page address.

ResponseDescription
200Allowed domains, alphabetically.
POST /v1/auto-og/sites Allow a domain

`host` may be a domain or a whole URL; it is stored lower-case without `www.` (`https://www.Example.com/blog` → `example.com`). IP addresses, single-label names and our own domain are rejected. At most 50 domains per account. `template` defaults to `site-card`; any template on your account works and gets the fields it declares out of `title`, `description`, `site`, `url`, `favicon`, `image`, `accent`, `theme`. `accent` (`#rrggbb`) and `theme` (`dark`/`light`) override the page; `null` = the page's `theme-color` (when it is a real colour) and the template default. `keep_query`: keep the page's query string (default `false`: dropped).

Example request

{
    "host": "example.com",
    "template": "site-card",
    "accent": "#2563eb",
    "theme": "light"
}
ResponseDescription
201The new entry.
422Invalid or duplicate `host`, unknown `template`, bad `accent`, or the domain limit reached.
PATCH /v1/auto-og/sites/{id} Change a domain

Send only what changes (same fields as creating). `accent: null` / `theme: null` go back to the page colour and the template default. Published `/u/` URLs stay the same; the next crawler request gets a card with the new settings.

ParameterInDescription
id path `aog_…`
ResponseDescription
200The updated entry.
404No such domain on this account.
422Validation failed (see creating).
DELETE /v1/auto-og/sites/{id} Remove a domain

From now on `/u/` URLs of its pages answer `403`. Images already rendered stay in your history until retention ends.

ParameterInDescription
id path
ResponseDescription
204Removed.
404No such domain on this account.
GET /u/{account}.{ext} Auto OG image of a page (for og:image)

One tag for a whole site: `<meta property="og:image" content="https://shareglint.com/u/acc_…png?url=PAGE">`. No API key and no signature — only pages whose host is on your allowed domains (`/auto-og/sites`) get images, any other host is a `403`.

We fetch the page (at most 5 s and 1.5 MB, private addresses and redirects off your domain refused), read its title, description, site name, icon, `og:image`, `theme-color` and canonical URL, and render a card with the domain's template. The query string is dropped unless the domain has `keep_query`; `utm_*`, `fbclid`, `gclid` and similar always are. What we read from a page is kept for 60 minutes. A page that cannot be read (error status, timeout, not HTML) gets a card with just the site name — never a broken image.

Caching and quota work like `GET /i/…`: the image is cached by the fields on the card, so an unchanged page is a `302` with no quota used, whatever address or tracking parameters it is shared with. A new card uses one render. Errors are short `text/plain` bodies. Limits: 120 requests per minute per account, 600 per IP.

ParameterInDescription
account path Your account `id` (`acc_…`). Public, not a credential.
ext path File format.
url query Absolute http(s) address of the page, URL-encoded.
ResponseDescription
302Redirect to the finished image.
400`url` missing or not an absolute http(s) address.
402Quota exhausted (only when a new card was needed).
403The page's domain is not on your allowed domains.
404No such account, or the domain's template was deleted.
429Rate limit. See `Retry-After`.
500The render failed. No quota is used.
503The render did not finish in time. It keeps going — retry after `Retry-After`.

Screenshots

POST /v1/screenshots Take a screenshot of a web page

Asynchronous by default, exactly like `POST /images`: 202 with an `id`, then `GET /screenshots/{id}` (or `GET /images/{id}` — screenshots share the render history). With `?wait=true` we hold the connection for up to 25 s and return 200, or 503 `sync_overloaded` with the `id`.

Pricing: a viewport screenshot uses 1 render of quota, a full-page one 2. The same URL with the same options within 24 h returns the existing file and uses nothing. A page that fails (4xx/5xx status, timeout, blocked address) gives `status: failed` with a reason in `error`, and the quota is returned. On the Free plan the screenshot carries the same small badge as images (bottom right; on a full page, at the bottom of the page).

The browser runs in an isolated network and reaches the internet only through an egress proxy that allows public addresses on ports 80 and 443 — redirects, frames and subresources pointing at private, loopback, link-local or metadata addresses are refused. Webhooks for screenshots carry the events `screenshot.completed` / `screenshot.failed`.

ParameterInDescription
wait query Wait for the result instead of polling.
Idempotency-Key header The same key with the same body returns the same response, with no second screenshot.

Example request

{
    "url": "https://example.com",
    "full_page": true,
    "format": "webp",
    "block_ads": true
}
ResponseDescription
200Finished (only with `?wait=true`).
202Accepted into the queue.
402Not enough quota for this screenshot (`quota_exceeded`).
422Validation error: missing or non-public `url`, viewport out of range, unknown `device`, `delay` too long, an unsafe selector.
500With `?wait=true`: the page could not be captured (`status: failed`, reason in `error`). No quota is used.
503Sync mode ran out of time (`sync_overloaded`). The body carries `id`.
GET /v1/screenshots/{id} Screenshot status
ParameterInDescription
id path
ResponseDescription
200The screenshot (`kind: screenshot`).
404No such render on this account.
GET /s/{account}.{ext} Signed screenshot URL (for <img> or og:image)

A screenshot as a plain URL, no API key: `/s/{account}.png?url=https%3A%2F%2Fexample.com&full_page=true&s=SIGNATURE`. Options are the `POST /screenshots` fields as query parameters (`hide` comma-separated); the extension picks the format. The signature is the same as for `GET /i/…` with the path `/s/{account}.{ext}`: canonical string = path + `?` + every parameter except `s`, sorted by name, RFC 3986-encoded, joined with `&`; `s` = first 32 hex chars of HMAC-SHA256(canonical, signing_secret).

A screenshot of the same URL and options from the last 24 h is a 302 with no quota used. Otherwise we capture synchronously (up to 25 s), charge like `POST /screenshots` and redirect. Redirects are cached for up to 1 hour; errors are short `text/plain` bodies. Same rate limits as `GET /i/…`.

ParameterInDescription
account path Your account `id` (`acc_…`). Public, not a credential.
ext path File format. Part of the signed path.
url query The page, URL-encoded.
s query Signature — see above.
{option} query Any `POST /screenshots` option: `full_page`, `viewport_width`, `viewport_height`, `device`, `dark_mode`, `delay`, `wait_for`, `hide`, `block_ads`, `scale`, `quality`. Anything else is a 400.
ResponseDescription
302Redirect to the finished file.
400Invalid or non-public `url`, an invalid option, or an unknown parameter.
402Quota exhausted (only when a new screenshot was needed).
403Missing, malformed or invalid signature.
404No such account.
429Rate limit. See `Retry-After`.
502The page could not be captured; the reason follows. No quota is used.
503The screenshot did not finish in time. It keeps going — retry after `Retry-After`.