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
| Mode | Response | When |
|---|---|---|
POST /v1/images | 202 + id | The default. Collect the result from GET /v1/images/{id} or by webhook. |
POST /v1/images?wait=true | 200 + gotowy url | When 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).
| Status | error | Meaning |
|---|---|---|
| 401 | unauthorized | Missing key, or a key that is invalid or revoked. |
| 402 | quota_exceeded | Monthly quota exhausted. |
| 404 | — | The resource does not exist on this account. Somebody else's resources are indistinguishable from nonexistent ones. |
| 422 | idempotency_key_reuse, last_key | Validation error or a conflict. |
| 429 | — | Too many requests per minute. |
| 503 | sync_overloaded | Sync 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.
| Plan | Image available for |
|---|---|
| Free | 30 days from generation |
| Starter, Pro, Scale | 365 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:
- The HTML is sanitised on save —
<script>,<iframe>andon*attributes are dropped. The response contains the sanitised version, so you see immediately what will actually render. - The CSS loses
@import,@font-faceand anyurl()outsidedata:. Network resources would not be fetched anyway. - Fonts come from our list. Field values are escaped, so user content can never become markup.
- We fetch image URLs ourselves and inline them as data — which is why an image field must be listed in
images.
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.
| Slug | Fields |
|---|---|
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
- Path:
/i/{account_id}/{template}.{ext}— your accountid(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.pngdoes not open.jpg. - Take every query parameter except
s(template fields and the optionallocale), sort them by name (byte order), percent-encode the name and the value per RFC 3986 — everything exceptA–Z a–z 0–9 - _ . ~, so a space is%20— and join them asname=valuewith&. - Canonical string = path +
?+ the joined parameters. The?stays even when there are no parameters. s= the first 32 lowercase hex characters ofHMAC-SHA256(canonical, signing_secret)(128 bits — plenty against guessing behind our rate limits, and half the URL length).- Append
sto 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
- The signing secret and your account
idare in the dashboard and atGET /v1/account(signing_secret,id). It is a separate secret from your API keys and the webhook secret: if it leaks, nobody gets API access — but they can render on your quota. - Rotate it through
POST /v1/account/signing-secret. Every URL signed with the old secret stops working immediately, including ones already published. Redirects already cached by crawlers and CDNs may live up to 1 h. - Stricter than the POST API: a field the template does not declare is a
400, so a typo cannot quietly ship an image with no title. Your own templates acceptfieldsandimages; built-in ones accept their documented fields plusaccentandtheme. - Errors are short
text/plainbodies, never HTML:400unknown field orlocale,402quota exhausted,403missing or invalid signature,404unknown account or template (indistinguishable),429rate limit,503render still running — retry afterRetry-After. - Limits: 120 correctly signed requests per minute per account (requests with a bad signature never count against yours) and 600 requests per minute per IP address.
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">
- The extension picks the format:
.png,.jpgor.webp. The page address goes URL-encoded after?url=. There is no signature: instead, only pages on your allowed domains (and their subdomains) get images; any other host is a403. - The image is cached by what is on the card, not by the address: an unchanged page never uses quota twice, even when shared as
?utm_source=…or under another address with the same<link rel="canonical">. We re-read a page at most every 60 minutes, so a new title shows up within that time. - The query string is dropped by default (tracking links would otherwise turn one page into many renders). Turn on
keep_queryfor a domain whose pages differ by query string;utm_*,fbclid,gclidand similar are dropped even then. - If the page cannot be read (an error page, a timeout, not HTML, a redirect off your domain), you get a card with just the site name instead of a broken image — the same card for every such page of the domain, so it costs one render.
- Your own template gets the fields it declares out of
title,description,site,url,favicon,image,accentandtheme. Set a template, accent colour and theme per domain. - A new card uses one render of your quota, exactly like
POST /v1/images. Errors are shorttext/plainbodies:400badurl,402quota exhausted,403domain not allowed,404unknown account,429rate limit. Limits: 120 requests per minute per account, 600 per IP address.
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=…">
- Screenshots run in a separate browser pool in an isolated network: its only way out is an egress proxy that allows public addresses on ports 80 and 443. Redirects, frames, images and scripts pointing at private, loopback, link-local (cloud metadata) or other internal addresses are refused after DNS resolution — so a redirect to
127.0.0.1or a rebinding domain fails just like a private address typed in directly. - Limits: viewport 320–3840 × 200–2160 px, full page up to 16,000 px tall,
delayup to 10 s, 20 s per page.
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).
- We store no IP address and no User-Agent — only the day, the platform, the template and a count.
- Only requests that reach us are counted. The
/i/redirect is cached for an hour (the file itself for a year), so crawlers come back through us; the fileurlfromPOST /v1/imagesis served straight from storage and is not counted.HEADrequests and errors are not counted either.
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.
| Parameter | In | Description |
|---|---|---|
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
}
}
| Response | Description |
|---|---|
200 | Finished (only with `?wait=true`). |
202 | Accepted into the queue. |
402 | Monthly quota exhausted (`quota_exceeded`). |
422 | Validation 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. |
503 | Sync 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.
| Parameter | In | Description |
|---|---|---|
limit |
query | |
before |
query | Cursor from `next_cursor`. |
| Response | Description |
|---|---|
200 | A page of renders, newest first. |
GET /v1/images/{id} Render status
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | The render. |
404 | No 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.
| Parameter | In | Description |
|---|---|---|
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. |
| Response | Description |
|---|---|
302 | Redirect to the finished file in the requested format. |
400 | A field the template does not declare, an unsupported `locale`, or a non-string parameter. |
402 | Monthly quota exhausted (only when a new render was needed). |
403 | Missing, malformed or invalid signature. |
404 | No such account or template. The two are indistinguishable. |
429 | Rate limit. See `Retry-After`. |
500 | The render failed. No quota is used. |
503 | The render did not finish in time. It keeps going — retry after `Retry-After`. |
Templates
GET /v1/templates List templates
| Response | Description |
|---|---|
200 | Templates 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>"
}
| Response | Description |
|---|---|
201 | Created. |
422 | A field used in the HTML is not declared, `images` goes beyond `fields`, or a size limit was exceeded. |
GET /v1/templates/{id} Template details
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | The template, with `editor_model`. |
404 | No 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`).
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | Saved. |
404 | No such template, or it is built-in. |
DELETE /v1/templates/{id} Delete a template
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
204 | Deleted. |
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.
| Response | Description |
|---|---|
200 | The 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.
| Parameter | In | Description |
|---|---|---|
slug |
path | Library slug, e.g. `job-posting`. |
Example request
{
"slug": "careers-card",
"name": "Careers card"
}
| Response | Description |
|---|---|
201 | The new template. |
404 | No 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"
}
}
| Response | Description |
|---|---|
200 | Preview 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"
}
| Response | Description |
|---|---|
200 | Proposed template, preview and today's usage. |
403 | Free plan: `plan_required`. |
422 | Neither `description` nor a valid `image`. |
429 | Daily 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.
| Response | Description |
|---|---|
200 | Available 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.
| Response | Description |
|---|---|
200 | The 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.
| Response | Description |
|---|---|
200 | The 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.
| Response | Description |
|---|---|
200 | The 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.
| Response | Description |
|---|---|
200 | The 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.
| Response | Description |
|---|---|
200 | Keys 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.
| Response | Description |
|---|---|
201 | The 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.
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
204 | Revoked. |
422 | This 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.
| Parameter | In | Description |
|---|---|---|
days |
query | How many days back, today included. |
| Response | Description |
|---|---|
200 | Counters 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.
| Response | Description |
|---|---|
200 | Allowed 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"
}
| Response | Description |
|---|---|
201 | The new entry. |
422 | Invalid 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.
| Parameter | In | Description |
|---|---|---|
id |
path | `aog_…` |
| Response | Description |
|---|---|
200 | The updated entry. |
404 | No such domain on this account. |
422 | Validation 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.
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
204 | Removed. |
404 | No 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.
| Parameter | In | Description |
|---|---|---|
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. |
| Response | Description |
|---|---|
302 | Redirect to the finished image. |
400 | `url` missing or not an absolute http(s) address. |
402 | Quota exhausted (only when a new card was needed). |
403 | The page's domain is not on your allowed domains. |
404 | No such account, or the domain's template was deleted. |
429 | Rate limit. See `Retry-After`. |
500 | The render failed. No quota is used. |
503 | The 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`.
| Parameter | In | Description |
|---|---|---|
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
}
| Response | Description |
|---|---|
200 | Finished (only with `?wait=true`). |
202 | Accepted into the queue. |
402 | Not enough quota for this screenshot (`quota_exceeded`). |
422 | Validation error: missing or non-public `url`, viewport out of range, unknown `device`, `delay` too long, an unsafe selector. |
500 | With `?wait=true`: the page could not be captured (`status: failed`, reason in `error`). No quota is used. |
503 | Sync mode ran out of time (`sync_overloaded`). The body carries `id`. |
GET /v1/screenshots/{id} Screenshot status
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | The screenshot (`kind: screenshot`). |
404 | No 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/…`.
| Parameter | In | Description |
|---|---|---|
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. |
| Response | Description |
|---|---|
302 | Redirect to the finished file. |
400 | Invalid or non-public `url`, an invalid option, or an unknown parameter. |
402 | Quota exhausted (only when a new screenshot was needed). |
403 | Missing, malformed or invalid signature. |
404 | No such account. |
429 | Rate limit. See `Retry-After`. |
502 | The page could not be captured; the reason follows. No quota is used. |
503 | The screenshot did not finish in time. It keeps going — retry after `Retry-After`. |