---
name: fastog-integration
description: Use when building, modifying, or reviewing any FastOG client — signing dynamic Open Graph image GET URLs (HMAC canonicalization), using the one-file SDKs, embedding og:image meta tags, or debugging FastOG API errors (401 missing_signature / invalid_signature, 402/422/502/503, invalid_image_url, render_unreachable, ...) from any server-side language — including the enriched error payload (hint, docs_url, request_id/X-Request-ID), the /docs/api-errors reference, and what to log server-side for diagnosability (request_id correlation, og:diagnose / renders:logs). Includes the complete offline catalog of all 56 templates, every template variable, all style enums (themes, patterns, fonts, 80 gradient presets), and output sizes/formats.
---

# FastOG — Client Integration

## Overview

FastOG renders dynamic Open Graph images on demand. A client's **server** signs a **GET** URL and embeds it in `<meta property="og:image">`; crawlers fetch it (GET only). Usage is metered by the **Dynamic API subscription quota** (a monthly plan) — each **render** uses one of the plan's monthly quota, and identical URLs are edge-cached so re-serving the same content costs nothing extra. (Studio is a separate, one-off product: buy credits → download static PNGs.)

The whole client surface is one function — `signedUrl(params, key, secret)` → a ready-to-embed URL. Everything below is the one true contract behind it.

## Download this skill for your agent / AI

You don't need to browse the docs page — load FastOG straight into Claude, Codex, Cursor, or any agent. Grab the two skill files (or one combined file) and drop them into your `agents/skills/fastog-integration/` directory:

```
curl -O https://fastog.com/skill/SKILL.md
curl -O https://fastog.com/skill/implementations.md
# or one file with everything inline:
curl -O https://fastog.com/docs-files/agent-integration.md
```

The one-file signer SDKs are served the same way (no package manager):

```
curl -O https://fastog.com/sdk/node.js        # Node / Express  (ogImageUrl)
curl -O https://fastog.com/sdk/svelte/fastog.js  # SvelteKit / Next / Astro / Vue
curl -O https://fastog.com/sdk/php/fastog.php.txt # PHP  (FastOg::signedUrl)
curl -O https://fastog.com/sdk/python/fastog.py   # Python  (fastog.signed_url)
curl -O https://fastog.com/sdk/ruby/fastog.rb     # Ruby  (FastOg.signed_url)
curl -O https://fastog.com/sdk/go/fastog.go       # Go  (fastog.SignedURL)
```

All `/skill/*`, `/docs-files/*`, and `/sdk/*` responses are cached immutable (`Cache-Control: max-age=31536000`), so re-fetching is free.

---

## Templates, variables & image kinds — complete offline reference

**Everything in this section is ground truth (no internet needed).** There are **56 templates**. Every template accepts the **shared params** below PLUS its **template-specific params** in the index table. Any unknown param is ignored by the renderer, so passing a superset is safe. `title` is the only required content param (max 300 chars, validated server-side).

### Output / image kinds

| Param | Values | Default | Notes |
| --- | --- | --- | --- |
| `format` | `png` \| `jpeg` \| `webp` | `jpeg` (quality 88) | Invalid values fall back to default |
| `size` | `og` \| `x` | `og` | `og` = 1200×630 (standard), `x` = 1500×500 (X/Twitter header). Every template forwards `size` to the canvas, so `size=x` works on any template; `xheader` defaults to `x`, all others default to `og` |
| `quality`, `scale` | int | `88`, `1` | render-service tuning, rarely used |
| `layout_mode` | per-template enum | — | `centered` \| `split` \| `banner` (game: `split`/`centered`; xheader: `basic`/`minimalist`/`logo`; product: `image-left`/`image-right`; promo: `split`/`centered`; hero: `split`/`centered`/`banner`; imagehero: `left`/`left-bottom`/`center`/`bottom`) |

Reserved params (never used as content): `s` (signature, excluded from canonical), `key` (included in canonical), `t` (reserved). `title`, `subtitle`, `tags`, `theme`, `bg_color`, `accent_color`, `gradient_preset`, `gradient_direction`, `pattern`, `pattern_color`, `pattern_intensity`, `font_family`, `auto_text_color`, `title_color`, `subtitle_color`, `brand_name`, `brand_logo`, `layout_mode` are stripped from template props and never reach the renderer as content.

### Shared params (accepted by ALL 56 templates)

| Param | Type | Allowed values / notes |
| --- | --- | --- |
| `title` | string | Main heading (required server-side, max 300) |
| `subtitle` | string | Supporting text |
| `tags` | list | Comma-separated tag labels, e.g. `Fast,Cheap,Secure` |
| `theme` | enum | `dark` \| `light` \| `glass` \| `solid` \| `midnight` \| `vivid` \| `paper` \| `glass_light` \| `custom` |
| `gradient_preset` | enum | **80 presets** — see full list below. Empty = solid `bg_color` gradient |
| `gradient_direction` | int | 0–360, default 135 (angle in degrees) |
| `bg_color` | hex | e.g. `#4F46E5` (start of gradient when no preset) |
| `pattern` | enum | `grid` \| `dots` \| `cross` \| `diagonal` \| `checkerboard` \| `circles` \| `zigzag` \| `waves` \| `noise` \| `plus` \| `triangles` \| `diamonds` \| `bricks` \| `hexagons` \| `stars` \| `none` |
| `pattern_color` | hex | Defaults to white (dark) / black (light) |
| `pattern_intensity` | int | 1–30, default 8 (alpha 0.01–0.30) |
| `font_family` | enum | `font-sans` \| `font-jakarta` \| `font-dm` \| `font-outfit` \| `font-space` \| `font-playfair` \| `font-merriweather` \| `font-instrument` \| `font-jetbrains` \| `font-fira` |
| `accent_color` | hex | Highlight color |
| `auto_text_color` | bool | `true`/`false` (default `true`) — derive optimal text color from bg |
| `title_color` | hex | Override title color |
| `subtitle_color` | hex | Override subtitle color |
| `brand_name` | string | Brand text, default `FastOG` |
| `brand_logo` | URL | Brand logo image URL (http/https only — other URL props rejected with 422 `invalid_image_url`) |

### Template index — all 56 templates and their variables

Shared params apply to every row; the column lists ONLY the template-specific extras.

| `template` | Label | Category | Template-specific params |
| --- | --- | --- | --- |
| `default` | Default | Brand | `domain` |
| `blog` | Blog Post | Content | `author`, `date`, `domain` |
| `ecommerce` | E-commerce | Commerce | `price`, `original_price`, `rating`, `rating_total`, `features` (list, up to 4), `badge`, `image`, `domain` |
| `company` | Company | Brand | `domain`, `logo`, `tag` |
| `launch` | Product Launch | Brand | `kicker`, `version`, `tag`, `cta`, `domain` |
| `saaslaunch` | SaaS Launch | Brand | `kicker`, `cta`, `badge_text`, `stat_number`, `stat_label`, `domain` |
| `devtool` | Developer Tool | Developer | `version`, `badge`, `tag` (when badge empty), `repo_or_brand`, `domain` |
| `game` | Game | Content | `price`, `rating`, `image`, `layout_mode` (`split`/`centered`), `domain` |
| `event` | Event | Social | `location`, `host`, `date`, `domain` |
| `quote` | Quote | Social | `quote`, `author_name`, `author_title`, `domain` |
| `job` | Job Posting | Social | `location`, `department`, `salary`, `job_type`, `company`, `remote` (bool), `logo`, `domain` |
| `stats` | Stats | Data | `metric`, `metric_label`, `delta`, `delta_direction` (`up`/`down`), `stats` (tiles, up to 3), `domain` |
| `pricing` | Pricing | Commerce | `price`, `currency`, `period`, `cta`, `badge`, `features` (list, up to 5), `domain` |
| `podcast` | Podcast | Media | `episode_number`, `season`, `episode`, `guest_name`, `guest_role`, `guest_avatar`, `show_name`, `duration`, `domain` |
| `video` | Video | Media | `duration`, `channel`, `views`, `thumbnail` |
| `editorial` | Editorial | Content | `kicker`, `author`, `date`, `image` (bg URL), `align` (`bottom`/`left`/`center`), `issue`, `domain` |
| `minimal` | Minimal | Brand | `label` (corner, defaults to brand), `footer_left`, `footer_right` (defaults to domain), `domain` |
| `changelog` | Changelog | Developer | `version`, `tag`, `date`, `changes` (changes format), `repo` |
| `corporate` | Corporate | Brand | `kicker`, `tag`, `domain` |
| `hero` | Hero | Brand | `kicker`, `cta`, `status`, `layout_mode` (`split`/`centered`/`banner`), `domain` |
| `imageright` | Image Right | Commerce | `kicker`, `image`, `domain` |
| `logos` | Logo Wall | Brand | `kicker`, `brand_logos` (list of image URLs), `domain` |
| `notice` | Notice | Social | `notice_status`, `notice_icon`, `notice_text`, `date`, `domain` |
| `testimonial` | Testimonial | Social | `quote`, `author_name`, `author_title`, `company`, `author_avatar`, `rating` (0–5), `rating_total`, `quote_style` (`modern`/`classic`/`minimal`) |
| `logofocus` | Logo Focus | Content | `kicker`, `domain` |
| `xheader` | X Header | Social | `size` (`og`/`x`), `handle`, `followers`, `location`, `website`, `avatar`, `layout_mode` (`basic`/`minimalist`/`logo`) |
| `bento` | Bento | Data | `kicker`, `tiles` (tiles format), `domain` |
| `comparison` | Comparison | Commerce | `kicker`, `left_label`, `left_desc`, `left_points` (list), `right_label`, `right_desc`, `right_points` (list), `verdict` |
| `timeline` | Timeline | Content | `kicker`, `milestones` (milestones format), `domain` |
| `countdown` | Countdown | Brand | `kicker`, `target_date` (YYYY-MM-DD), `cta`, `domain` |
| `news` | News | Content | `headline`, `byline`, `author`, `date`, `source`, `image` (bg URL) |
| `code` | Code | Developer | `code`, `language`, `terminal_title`, `author`, `handle`, `domain` |
| `testimonialpro` | Testimonial Pro | Social | `quote`, `author_name`, `author_title`, `company`, `author_avatar`, `rating`, `rating_total`, `cta` |
| `product` | Product | Commerce | `badge`, `title`, `price`, `original_price`, `rating`, `rating_total`, `features` (list), `image`, `layout_mode` (`image-left`/`image-right`), `domain` |
| `promo` | Promo | Brand | `brand_name`, `benefits` (list), `title`, `subtitle`, `domain`, `layout_mode` (`split`/`centered`) |
| `devblog` | Dev Blog | Content | `category`, `title`, `subtitle`, `tags` (list), `author`, `author_title`, `date`, `read_time`, `views`, `likes`, `comments`, `code`, `language`, `file_path` |
| `codepost` | Code Post | Content | `category`, `date`, `title`, `subtitle`, `author`, `read_time`, `code`, `file_path`, `language` |
| `statsbar` | Stats Bar | Data | `kicker`, `metric`, `metric_label`, `delta`, `stats` (tiles), `domain` |
| `changelogpro` | Changelog Pro | Developer | `version`, `tag`, `title`, `changes` (changes format), `features` (list), `domain` |
| `quotecta` | Quote CTA | Social | `quote`, `author_name`, `author_title`, `company`, `cta`, `domain` |
| `appscreenshot` | App Screenshot | Media | `badge`, `title`, `subtitle`, `app_name`, `rating`, `screenshot`, `domain` |
| `imagehero` | Image Hero | Media | `kicker`, `cta`, `image`, `image_url`, `overlay` (0–90 scrim darkness), `layout_mode` (`left`/`left-bottom`/`center`/`bottom`), `domain` — full-bleed bg image + overlaid copy |
| `gamestore` | Game Storefront | Media | `edition`, `price`, `rating`, `tags` (list, genre chips), `studio`, `image`, `image_url`, `domain` — full-bleed box-art hero |
| `productshot` | Product Shot | Media | `badge`, `brand`, `price`, `old_price`, `rating`, `image`, `image_url`, `domain` — big photography panel |
| `photocard` | Photo Card | Media | `badge`, `location`, `credit`, `image`, `image_url`, `domain` — one framed hero photo |
| `splitimage` | Split Image | Media | `label1`, `label2`, `cta`, `image`, `image_url`, `image2` (right), `domain` — two images 50/50 |
| `collage` | Gallery | Media | `kicker`, `cta`, `image`, `image_url`, `image2`, `image3`, `image4` (bento mosaic), `domain` |
| `monodepth` | Mono Depth | Media | `kicker`, `image`, `image_url`, `tilt` (2–25), `cta` — layered 3D diorama |
| `godray` | God Ray | Media | `kicker`, `image`, `image_url`, `light_color`, `beam_strength` (0–100), `cta` — volumetric light |
| `chromeorb` | Chrome Orb | Media | `kicker`, `orb_color`, `cta` — glossy CSS orb + floor grid |
| `iridescent` | Iridescent | Media | `kicker`, `rule`, `hue1`, `hue2`, `hue3` — holographic sheen + grain |
| `neonbloom` | Neon Bloom | Media | `kicker`, `neon`, `neon2`, `glow` (0–100), `image`, `image_url`, `cta` — glass + neon |
| `tilt3d` | Depth Prism | Media | `kicker`, `cta`, `solid` (3–8 sides), `tilt` (0–45 yaw), `face` (−20–40 pitch) — real 3D prism in SVG |
| `globe3d` | Geodesic Globe | Media | `kicker`, `cta`, `detail` (6–36), `bands` (4–16), `lat`, `lon`, `frame` (8–48) — geodesic wireframe |
| `platform3d` | Iso Platform | Media | `kicker`, `cta`, `cols` (2–8), `rows` (2–7), `cell` (18–60), `maxh` (40–220) — isometric blocks |
| `studio3d` | 3D Studio | Media | `preset` (`3d-iso`\|`frame-break`\|`data-mesh`\|`clay-glass`\|`code-3d`); per-preset params below |

**`studio3d` presets — one template, five render modes.** Set `preset` to switch. Each mode has its own params (all optional; shared params still apply):

| `preset` | Mode | Extra params |
| --- | --- | --- |
| `3d-iso` | Isometric studio | `kicker`, `cta`, `camera_pitch` (deg, `-20–70`), `camera_yaw` (deg, `-90–90`) — an isometric 3D solid on a tile with rim light + AO floor |
| `frame-break` | Pseudo-3D parallax + pop-out | `kicker`, `cta`, `bg_blur` (px, `0–20` bokeh), `shadow_elevation` (`0–50`), `popout_asset_url` (breaks the card edge), `card_tilt` (deg, `-8–8`) |
| `data-mesh` | Generative seeded mesh | `target_url` (seeds SHA-256 → resulting palette), `density` (`low`\|`med`\|`high`), `color_override` (hex, optional) — deterministic network mesh/wave behind the title |
| `clay-glass` | Claymorphism + backdrop blur | `frosted_blur` (px, `2–30`), `clay_color` (hex), `light_angle` (deg, `0–360`), `border_highlight` (bool) |
| `code-3d` | Cyberpunk code terminal | `code` (snippet), `lang` (`js`\|`php`\|`python`\|`json`), `glow_color` (hex), `cg_theme` (`cyberpunk`\|`dracula`\|`monokai`) — highlight.js + CSS 3D tilt terminal |

`data-mesh` note: colors derive from the `target_url` hash, so **different URLs → different covers**. Automatic scanning of a page's actual dominant colors is not performed (SSRF posture); pass `color_override` for an explicit single color, or leave it unset to get the hash-derived palette.

### Structured param formats (how arrays/objects serialize into a signed URL)

| Type | Format | Example |
| --- | --- | --- |
| `list` | Comma-separated string → array | `features=Fast,Cheap,Secure` → `["Fast","Cheap","Secure"]` |
| `tiles` | Line-separated `label\|value` pairs (newline = `%0A`) | `stats=Requests\|12.4M%0AUptime\|99.99%25` |
| `milestones` | Line-separated `date\|title\|description` | `milestones=Q3 2026\|Alpha\|Invite-only` |
| `changes` | Line-separated `type\|text` where type ∈ `added`\|`fixed`\|`improved`\|`removed` | `changes=added\|Bento templates` |

**Array signing rule (CRITICAL — 401 if wrong):** sign the canonical with the *plain* repeated key (`features=Fast&features=Cheap`), but the URL must use *bracket* keys (`features[0]=Fast&features[1]=Cheap`). The PHP backend decodes repeated non-bracket keys to a scalar (last wins) → canonical mismatch → 401.

### `gradient_preset` — all 80 allowed values

`indigo`, `ocean`, `sunset`, `forest`, `rose`, `midnight`, `aurora`, `ember`, `twilight`, `slate`, `mint`, `lavender`, `coral`, `steel`, `cherry`, `sky`, `earth`, `grape`, `copper`, `navy`, `blush`, `pine`, `violet`, `gold`, `storm`, `neon`, `wine`, `peach`, `arctic`, `obsidian`, `cyber`, `frost`, `fire`, `moss`, `candy`, `shadow`, `tropical`, `blossom`, `ember_glow`, `deep_sea`, `solar`, `cosmic`, `sage`, `berry`, `dusk`, `ice`, `ruby`, `lagoon`, `plum`, `sand`, `midnight_blue`, `spring`, `magenta`, `charcoal`, `caramel`, `electric`, `rosewood`, `teal`, `horizon`, `amethyst`, `crimson`, `jade`, `cobalt`, `stripe`, `nebula`, `violet_fusion`, `synthwave`, `grape_soda`, `azure`, `aloe`, `teal_night`, `golden_hour`, `sunrise`, `bubblegum`, `rose_gold`, `lilac_mist`, `ivory`, `seafoam`, `graphite`, `neo`

### URL-bearing props (fetched server-side — SSRF-guarded, http/https only)

`image`, `image_url`, `image2`, `image3`, `image4`, `brand_logo`, `logo`, `avatar`, `background_image`, `background`, `banner`, `cover`. An unsafe value → `422` `invalid_image_url` (not cacheable).

---

## When to use the SDK vs hand-roll

There is **no package-manager SDK** (`npm i`, `pip install`, etc.) yet — the "SDKs" are **single-file signers**: they only generate the signed OG image URL (all the HMAC + RFC-3986 logic), no runtime, no deps, and are downloadable as a file.

- **Use a one-file signer** for Node, Svelte, Vue, Next.js, Astro, Express, PHP, Ruby, Python, Go — zero deps, tested against the server, downloadable at `fastog.com/sdk/...` (table below).
- **Hand-roll only** for a language not covered — the contract is ~40 lines; copy the exact algorithm from **[`implementations.md`](./implementations.md)** in this skill.

## The contract (the only one)

```
URL        = https://fastog.com/api/v1/og?<content>&key=<apiKey>&s=<signature>
canonical  = every query param except `s` (INCLUDING `key`), keys sorted,
             array values sorted, each pair enc(key)=enc(value) RFC-3986,
             joined with "&"
message    = "GET/api/v1/og" + "\n" + canonical
signature  = hex(HMAC-SHA256(message, hmac_secret))
```

### Invariants (breaking any one → 401)

1. **RFC-3986 encoding** — percent-encode everything except `A-Za-z0-9-._~`; **space → `%20`, never `+`**. PHP `rawurlencode`; JS `encodeURIComponent` + re-encode `!'()*`; Python `quote(safe='-._~')`; Ruby/Go custom byte encoder.
2. **`key` is signed** — it is part of the canonical query, so the signature is bound to the key.
3. **Arrays need bracket keys in the URL** — sign the canonical with the plain key (`features=…&features=…`), but the URL must use `features[0]=…` or `features[]=…`. The backend is PHP: repeated non-bracket keys (`features=a&features=b`) decode to a **scalar** (last wins) → canonical mismatch → 401.
4. **No timestamp** — content-addressed. Identical params → identical URL → cacheable forever; any change → 401 → no render, no quota consumed.

Reserved params: `s` (signature — excluded from canonical), `key` (included), `t` (reserved — never use).

### Security (non-negotiable)

- `hmac_secret` is **private** — server-side only. Never in a browser, never in a public CDN script. Client-side/browser signing can never work.
- `key` is **public** (it appears in every URL) — expected and safe, because forging needs the secret.
- Only **GET** (crawlers never POST). Sign on the server at request time; the signed URL is what you embed.

### Worked example

```
params    = {template:"blog", title:"Hello World", features:["Free shipping","30-day returns"], key:"sk_test123"}
secret    = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

canonical = features=30-day%20returns&features=Free%20shipping&key=sk_test123&template=blog&title=Hello%20World
signature = a79232b15b45002aee76174f542e0a661b37cb1259df01e263c952ca4052a54d
url       = https://fastog.com/api/v1/og?template=blog&title=Hello%20World&features%5B0%5D=Free%20shipping&features%5B1%5D=30-day%20returns&key=sk_test123&s=a79232b15b45002aee76174f542e0a661b37cb1259df01e263c952ca4052a54d
```

## One-file SDKs (zero deps, no package manager)

Download and sign on the client's server:

| Language/Framework | File | One-liner |
| --- | --- | --- |
| Node.js (CJS) / Express | `https://fastog.com/sdk/node.js` · `/sdk/express/fastog.js` | `ogImageUrl(params, { key, secret })` |
| ESM (SvelteKit / Next / Astro / Vue-Nuxt) | `/sdk/svelte/fastog.js` · `/sdk/next/fastog.js` · `/sdk/astro/fastog.js` · `/sdk/vue/fastog.js` | `ogImageUrl(params, { key, secret })` |
| PHP | `https://fastog.com/sdk/php/fastog.php.txt` | `FastOg::signedUrl($params, $key, $secret)` |
| Ruby / Rails | `https://fastog.com/sdk/ruby/fastog.rb` | `FastOg.signed_url(params, key, secret)` |
| Python | `https://fastog.com/sdk/python/fastog.py` | `fastog.signed_url(params, key, secret)` |
| Go | `https://fastog.com/sdk/go/fastog.go` | `fastog.SignedURL(params, key, secret)` |

Embed the result:

```html
<meta property="og:image" content="https://fastog.com/api/v1/og?…&key=…&s=…" />
```

## Golden vector (self-verify ANY implementation)

```
params    = {template:"blog", title:"Hello World", features:["Free shipping","30-day returns"], key:"sk_test123"}
secret    = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
canonical = features=30-day%20returns&features=Free%20shipping&key=sk_test123&template=blog&title=Hello%20World
signature = a79232b15b45002aee76174f542e0a661b37cb1259df01e263c952ca4052a54d
```

An implementation is correct **iff** it produces that canonical and that signature for those inputs — and a different param/key must change the signature. The golden vector is generated by the FastOG server itself, so it is the ground truth.

## Diagnosing failures (reading API errors)

Every FastOG API error response is JSON with a stable `code`, plus:
- `hint` — short plain-language "what's wrong + what to try"
- `docs_url` — a link to the exact fix on the `/docs/api-errors` reference page
- `request_id` — a correlator, echoed in the `X-Request-ID` response header and recorded on the matching `renders_log` row (visible in the dashboard **Render Logs** table)

The full, current explanation + fix for every code lives in the reference doc: **`/docs/api-errors`** (source: `docs/api-errors.md`). When debugging a client, prefer pointing at that page over re-deriving the fix here.

### Signing / auth (HTTP 401)

| Code | Meaning | Fix |
| --- | --- | --- |
| `missing_signature` | URL missing `key` or `s` | regenerate with a signer; verify both params are present |
| `invalid_signature` | canonical mismatch or wrong secret | re-sign after ANY content change; same `hmac_secret` on both sides; space is `%20` (not `+`); arrays use bracket keys in the URL; `key` is in the canonical |
| `invalid_signature_format` | `s` is not 64-char hex | emit raw SHA-256 hex (lowercase `a-f0-9`), not base64 |
| `expired_timestamp` | legacy `t` older than 5 min | refresh the timestamp; the current v2 flow has no timestamp |
| `invalid_api_key` | `key` doesn't exist | use a key created in the dashboard (public, starts `sk_`) |
| `missing_hmac_secret` | key has no secret | recreate the API key in the dashboard |

### Other failures (not 401)

| Code | HTTP | Meaning / fix |
| --- | --- | --- |
| `invalid_image_url` | 422 | URL-bearing prop (`image`, `brand_logo`, `logo`, `avatar`, `background_image`, `background`, `banner`, `cover`) isn't a public http(s) URL |
| `insufficient_credits` | 402 | top up in dashboard; response includes `credits_remaining` + `top_up_url`; no credit charged on failures |
| `render_unreachable` | 503 | render service briefly down; retry in seconds; no credit charged |
| `render_failed` | 502 | check template props (bad/long values, upstream image down); response includes `status` |

### Correlating a failure to the ledger

Pass the `request_id` (or `X-Request-ID` header) to FastOG support or look it up in the dashboard **Render Logs** — every terminal outcome row (success + every rejection) carries the same `request_id`, its `error_code`, `status_code`, and the exact requested `params`. This turns "a URL failed" into "this exact request, this stage, this fix."

## Logging (what the integrating agent's client should emit)

Log server-side at the point you call FastOG so a "URL failed" becomes diagnosable from your own logs alone (you reference the site, FastOG support, or the FastOG dashboard with a `request_id`). This is the minimal structured field set; keep it in one line per request for cheap filtering.

| Field | Source | Why |
| --- | --- | --- |
| `req_id` | FastOG response `request_id` (or `X-Request-ID` header) | THE correlator — matches your log to FastOG's `renders_log` row / dashboard **Render Logs**. Log it on EVERY response, including the image (canonical) |
| `http_status` | response status | 200 = ok; 401/402/422/502/503 = failure bucket |
| `code` | response JSON `code` (on non-200) | stable error code to filter/group by |
| `template` | the `template` param sent | which render path |
| `params` | the non-secret content params sent (template, title, ...) | reproduce exact request (omit `s`; keep `key_prefix` only, never the full secret-bearing URL) |
| `latency_ms` | round-trip time | spot slow renders |
| `url`/`cache_key` | the signed URL (or a hash of it) | de-duplicate; identical URL = cached + re-served free |

**Rules**
- **Never log the full `hmac_secret`** (or the full signed URL if you consider the signature sensitive) — log a truncated `key_prefix` (e.g. `sk_Quick_a1b2`) instead. The secret never leaves your server and never belongs in logs.
- **Log on success too**, not only failures — without a success baseline you can't tell a spike in 401s from normal traffic.
- If you can't store the whole `request_id`, store its first 8 chars + a hash — enough to correlate with support.
- On any non-200, also log the response **`hint`** and **`docs_url`** so an engineer skimming logs sees the recommended fix immediately.

**Verifying from the FastOG side (when you have CLI/dashboard access):**

- `php artisan renders:logs --status=error [--code=...]` — pull the ledger rows fastog-side; the row's `request_id` matches your `req_id`.
- `php artisan og:diagnose <url>` — walk the pipeline stage by stage and report which stage failed (signing vs credits vs render).
- If a client URL fails but `og:diagnose --key=<prefix>` (built with this app's own signer + the key's DB secret) passes, the pipeline is healthy and the bug is in the client's signing/params.

## Updating when the contract changes

The contract is frozen (no clients). If it ever changes: update this skill + `implementations.md`, bump the SDK files, and update the golden vector — every SDK's test suite asserts the vector, so a change surfaces as test failures.

