# Shareglint — API reference

Open Graph images from a single HTTP request.

**How long images live.** Free plan: 30 days from generation. Paid plans: 365 days. After that both the file and the history entry are gone, and the same render ordered again no longer hits the cache and counts against your quota.

- Base URL: `https://shareglint.com/v1`
- Authentication: `Authorization: Bearer sk_live_...`
- Errors: JSON `{"error": "<stable code>", "message": "<text in your Accept-Language>"}`
- Machine-readable contract: https://shareglint.com/openapi.json
- Human version with guides (webhooks, signed URLs, idempotency): https://shareglint.com/en/docs

## `POST /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.

Parameters:

- `wait` (query, boolean): Wait for the result instead of polling.
- `Idempotency-Key` (header, string): 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.
- `template` (body, application/json, string, required): Template slug: one of yours or a built-in one from `GET /templates/library`.
- `modifications` (body, application/json, object): Values for the template fields. A field you leave out takes the template's `defaults` value, or stays empty — omitting `modifications` altogether renders the defaults.
- `locale` (body, application/json, string) [one of `"en"`, `"pl"`, `"de"`, `"es"`, `"fr"`]: Language for text the template adds itself (date, reading time).
- `webhook_url` (body, application/json, string): We call this URL when the render finishes. Private-network addresses are rejected with a 422 at request time, so a webhook that could never fire never costs you a render.
- `format` (body, application/json, string) [one of `"png"`, `"jpeg"`, `"webp"`, `"pdf"`; default `"png"`]: Output file. `pdf` is a single page exactly the template size in CSS px (1200×630 for the built-in templates), no margins, backgrounds printed, text kept as vector. The Free plan watermark applies to every format.
- `quality` (body, application/json, integer) [default `85`; min 1; max 100]: Compression quality for `jpeg` and `webp`. Ignored for `png` (lossless) and `pdf`.
- `scale` (body, application/json, integer) [one of `1`, `2`; default `2`]: Pixel density for `png`, `jpeg` and `webp`: 2 gives 2400×1260 for a 1200×630 template, 1 gives exactly 1200×630. Ignored for `pdf`.

Responses:

- `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 /images` — Render history

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

Parameters:

- `limit` (query, integer) [default `25`; max 100]
- `before` (query, string): Cursor from `next_cursor`.

Responses:

- `200` A page of renders, newest first.

## `GET /images/{id}` — Render status

Parameters:

- `id` (path, string, required)

Responses:

- `200` The render.
- `404` No such render on this account.

## `GET /templates` — List templates

Responses:

- `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 /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.

Responses:

- `201` Created.
- `422` A field used in the HTML is not declared, `images` goes beyond `fields`, or a size limit was exceeded.

## `GET /templates/{id}` — Template details

Parameters:

- `id` (path, string, required)

Responses:

- `200` The template, with `editor_model`.
- `404` No such template on this account.

## `PATCH /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`).

Parameters:

- `id` (path, string, required)

Responses:

- `200` Saved.
- `404` No such template, or it is built-in.

## `DELETE /templates/{id}` — Delete a template

Parameters:

- `id` (path, string, required)

Responses:

- `204` Deleted.

## `GET /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.

Responses:

- `200` The library, in gallery order.

## `POST /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.

Parameters:

- `slug` (path, string, required): Library slug, e.g. `job-posting`.

Responses:

- `201` The new template.
- `404` No built-in template with this slug.
- `422` `slug` is taken on this account or malformed.

## `POST /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.

Responses:

- `200` Preview URL.

## `POST /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.

Parameters:

- `description` (body, multipart/form-data, string) [max length 2000]: What the image should look like. Required without `image`.
- `image` (body, multipart/form-data, string): Reference design. Required without `description`.

Responses:

- `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 /fonts` — Available fonts

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

Responses:

- `200` Available font families.

## `GET /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.

Responses:

- `200` The current period.

## `GET /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.

Parameters:

- `days` (query, integer) [default `30`; min 1; max 90]: How many days back, today included.

Responses:

- `200` Counters for the period.
- `422` `days` outside 1–90.

## `GET /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.

Responses:

- `200` The account.

## `POST /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.

Responses:

- `200` The new secret.

## `POST /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.

Responses:

- `200` The new secret.

## `GET /i/{account}/{template}.{ext}` — Signed image URL (for og:image)

Base URL for this operation: `https://shareglint.com` (no API key).

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.

Parameters:

- `account` (path, string, required): Your account `id` from `GET /account` (`acc_…`). Public, not a credential.
- `template` (path, string, required): Template slug, e.g. `blog-og`.
- `ext` (path, string, required) [one of `"png"`, `"jpg"`, `"webp"`, `"pdf"`]: File format. Part of the signed path: a signature for `.png` does not open `.pdf`.
- `s` (query, string, required): Signature — see above.
- `locale` (query, string) [one of `"en"`, `"pl"`, `"de"`, `"es"`, `"fr"`]: Language for text the template adds itself. Signed like any other parameter.
- `{field}` (query, string): Any field the template declares (`fields`, `images`, `defaults`; built-in templates: their documented fields plus `accent` and `theme`). An undeclared field is a 400.

Responses:

- `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`.

## `GET /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.

Responses:

- `200` Allowed domains, alphabetically.

## `POST /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).

Parameters:

- `host` (body, application/json, string, required)
- `template` (body, application/json, string|null)
- `accent` (body, application/json, string|null)
- `theme` (body, application/json, string|null) [one of `"dark"`, `"light"`, `null`]
- `keep_query` (body, application/json, boolean) [default `false`]

Responses:

- `201` The new entry.
- `422` Invalid or duplicate `host`, unknown `template`, bad `accent`, or the domain limit reached.

## `PATCH /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.

Parameters:

- `id` (path, string, required): `aog_…`

Responses:

- `200` The updated entry.
- `404` No such domain on this account.
- `422` Validation failed (see creating).

## `DELETE /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.

Parameters:

- `id` (path, string, required)

Responses:

- `204` Removed.
- `404` No such domain on this account.

## `GET /u/{account}.{ext}` — Auto OG image of a page (for og:image)

Base URL for this operation: `https://shareglint.com` (no API key).

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.

Parameters:

- `account` (path, string, required): Your account `id` (`acc_…`). Public, not a credential.
- `ext` (path, string, required) [one of `"png"`, `"jpg"`, `"webp"`]: File format.
- `url` (query, string, required): Absolute http(s) address of the page, URL-encoded.

Responses:

- `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`.

## `POST /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`.

Parameters:

- `wait` (query, boolean): Wait for the result instead of polling.
- `Idempotency-Key` (header, string): The same key with the same body returns the same response, with no second screenshot.
- `url` (body, application/json, string, required): Public http(s) address of the page. Private, loopback and internal addresses are refused (422) — and so is anything the page redirects or links to that is not public, on ports other than 80/443.
- `format` (body, application/json, string) [one of `"png"`, `"jpeg"`, `"jpg"`, `"webp"`, `"pdf"`; default `"png"`]: `pdf`: one page the size of the viewport (full page: as many viewport-sized pages as needed), backgrounds printed, text kept as vector.
- `quality` (body, application/json, integer) [default `85`; min 1; max 100]: For `jpeg`/`webp` only.
- `scale` (body, application/json, integer) [one of `1`, `2`; default `1`]: Device pixel ratio for rasters: 2 = retina (twice the pixels). Ignored for `pdf`.
- `viewport_width` (body, application/json, integer) [default `1280`; min 320; max 3840]
- `viewport_height` (body, application/json, integer) [default `800`; min 200; max 2160]
- `device` (body, application/json, string) [one of `"desktop"`, `"laptop"`, `"tablet"`, `"mobile"`]: Preset viewport; `tablet` and `mobile` also emulate a touch device with a mobile user agent. Overrides `viewport_width`/`viewport_height`.
- `full_page` (body, application/json, boolean) [default `false`]: The whole scrollable page instead of the viewport, up to 16,000 CSS px tall. Costs 2 renders instead of 1.
- `dark_mode` (body, application/json, boolean) [default `false`]: Emulate `prefers-color-scheme: dark`.
- `delay` (body, application/json, integer) [default `0`; min 0; max 10]: Extra seconds to wait after the page has loaded (animations, lazy content).
- `wait_for` (body, application/json, string) [max length 200]: CSS selector to wait for before capturing (within the 20 s page limit).
- `hide` (body, application/json, array): CSS selectors to hide (chat widgets, promos). Plain selectors: no `{`, `}`, `;` or `<`. In `GET /s/…`: one comma-separated value.
- `block_ads` (body, application/json, boolean) [default `false`]: Block common ad networks and hide the usual cookie-consent banners (a small built-in list, not a full ad blocker).
- `webhook_url` (body, application/json, string): We call this URL when the screenshot finishes. Private-network addresses are rejected with a 422.

Responses:

- `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 /screenshots/{id}` — Screenshot status

Parameters:

- `id` (path, string, required)

Responses:

- `200` The screenshot (`kind: screenshot`).
- `404` No such render on this account.

## `GET /s/{account}.{ext}` — Signed screenshot URL (for <img> or og:image)

Base URL for this operation: `https://shareglint.com` (no API key).

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/…`.

Parameters:

- `account` (path, string, required): Your account `id` (`acc_…`). Public, not a credential.
- `ext` (path, string, required) [one of `"png"`, `"jpg"`, `"webp"`, `"pdf"`]: File format. Part of the signed path.
- `url` (query, string, required): The page, URL-encoded.
- `s` (query, string, required): Signature — see above.
- `{option}` (query, string): 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.

Responses:

- `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`.

## `GET /keys` — List API keys

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

Responses:

- `200` Keys on this account.

## `POST /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.

Responses:

- `201` The new key.

## `DELETE /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.

Parameters:

- `id` (path, string, required)

Responses:

- `204` Revoked.
- `422` This is the last active key (`last_key`).
