# Blog & image credits (https://opinly.ai/docs/billing)
# Understanding Blog Credits and Image Credits [#understanding-blog-credits-and-image-credits]
## How Credits Work [#how-credits-work]
Blog Credits and Image Credits are usage-based resources that power your content generation on Opinly. Unlike subscription fees that cover access to the platform, Credits are consumed when you perform specific actions:
* **Blog Credits** are used each time you generate a blog post
* **Image Credits** are used each time you generate an image
Credits are consumed immediately when you use a feature. They're not tied to time periods or subscription cycles. Think of them like units of work: once you've used a Credit to generate content, that Credit has been consumed.
## Why Credits Are Non-Refundable [#why-credits-are-non-refundable]
Credits are consumed the moment you use them to generate content. This means:
1. **Immediate consumption**: As soon as you generate a blog post or image, the required Credits are deducted from your account
2. **Resource usage**: Each Credit represents computational resources and AI processing that has already been used
3. **No reversal**: Once Credits are consumed, they cannot be restored, even if you later delete the generated content or cancel your subscription
This policy ensures we can maintain fair pricing and continue providing reliable content generation services to all customers.
## What Happens When You Cancel [#what-happens-when-you-cancel]
If you cancel your subscription:
* **Future billing stops**: You won't be charged for upcoming subscription renewals or additional Credits
* **Used Credits remain consumed**: Any Credits you've already used cannot be refunded or restored
* **Unused Credits**: Any unused Credits in your account remain available until they expire (if applicable) or are used
Cancellation prevents future charges but doesn't reverse past usage. This is similar to how other usage-based services work: once you've used a resource, you've consumed it.
## Managing Your Credits [#managing-your-credits]
To make the most of your Credits:
* Review your usage in the billing dashboard to track Credit consumption
* Plan your content generation to align with your Credit allocation
* Monitor your remaining Credits before starting large batch operations
If you have questions about your Credit usage or need help understanding your billing, please contact our support team at [support@opinly.ai](mailto:support@opinly.ai).
---
# Core concepts (https://opinly.ai/docs/concepts)
The SDK has three jobs: **fetch** content, **render** it, and **describe** it for search engines.
Each is handled by a different layer so you can adopt only what you need.
## Content is Tiptap JSON [#content-is-tiptap-json]
Your posts are stored as structured **Tiptap** (ProseMirror) JSON — not HTML, not Markdown. A
document is a tree of typed nodes (`paragraph`, `heading`, `image`, `bulletList`, `table`, …)
with inline marks (`bold`, `link`, `code`, …). The SDK calls this shape `OpinlyNode`.
Structured JSON means you control exactly how each node renders — wrap headings, lazy-load
images, restyle quotes — without parsing HTML strings. Images are pre-resolved server-side to a
stable `fileKey` that maps to your CDN, so the client never deals with raw upload IDs.
## Taxonomy prefixes [#taxonomy-prefixes]
Categories and authors live under their own **URL prefix** — `/blog/category/...` and
`/blog/authors/...`. Slugs come from Opinly; the prefix (the URL shape) is yours, set via
`OpinlyConfig`'s `categoryPrefix` (default `category`) and `authorPrefix` (default `authors`). In
Next.js you set them on `withOpinlyConfig`; the SDK's URL builders, `buildMetadata`, sitemap and
JSON-LD all emit URLs that match. Keep them next to your routing so the two can't drift.
Post permalinks are **flat** — a post is `/blog/`, addressed by a single company-unique
slug (never nested under its category). The prefix applies to the category/author *archive* pages
only (the same way WordPress's "category base" works).
## Routing: prefix → typed endpoint [#routing-prefix--typed-endpoint]
Because the taxonomy is prefixed, the URL itself tells you what a route is — you route by the
**first segment**, no server round-trip to disambiguate. Each branch maps to one typed call:
```ts
// catch-all handler, with `slug: string[]` and your configured prefixes
if (slug.length === 0) {
const { data: posts } = await opinly.posts({ limit: 12 }) // the blog index
const categories = await opinly.categories()
} else if (slug[0] === categoryPrefix) {
const { data: posts } = await opinly.posts({ category: slug[1] }) // a category archive
} else if (slug[0] === authorPrefix) {
slug[1]
? await opinly.author(slug[1]) // one author + their posts
: await opinly.authors() // the authors directory
} else if (slug.length === 1) {
const post = await opinly.post(slug[0]) // a single post by its flat slug (or null)
}
```
A **category page is just `posts({ category })`** — cursor-paginated, no separate "category
resolve". `post(slug)` fetches one post by its flat, company-unique slug and returns the
`FullPost`, or `null` if nothing's there.
Lists are **cursor-paginated**: `posts()` returns `{ data, has_more, next_cursor }`; pass
`next_cursor` back as `cursor` for the next page.
## Rendering: agnostic core + framework renderer [#rendering-agnostic-core--framework-renderer]
`@opinly/shared` walks the `OpinlyNode` tree and produces output. It's pure and framework-free,
exposed two ways:
* **`renderToHtml(content, { config })`** → an HTML string (great for RSS, email, or SSR where
you just want markup).
* **`createRenderer({ renderFn, config })`** → a generic walker that builds *your framework's*
elements. The framework packages wrap this for you:
| You write | Under the hood |
| --------------------------------------- | --------------------------------------------------- |
| `` from `@opinly/react` | `createRenderer({ renderFn: React.createElement })` |
| `` from `@opinly/vue` | `createRenderer({ renderFn: h })` |
| `` from `@opinly/svelte` | `renderToHtml(...)` via `{@html}` |
You never call the renderer directly unless you want to — the components are the public surface.
## Config [#config]
Every render/SEO call takes an `OpinlyConfig`:
```ts
interface OpinlyConfig {
imagesPrefix: string // where images resolve, e.g. "/images"
siteUrl?: string // "https://example.com" (for canonical URLs + JSON-LD)
blogPrefix?: string // "/blog"
siteName?: string // "Acme Blog"
}
```
In Next.js, `@opinly/next` populates this from the env vars `withOpinlyConfig` injects, so you
read it from `opinlyConfig`. In Nuxt/SvelteKit you pass the object directly.
## SEO [#seo]
`@opinly/shared` turns a resolved route into neutral metadata (`buildMetadata`) and schema.org
JSON-LD (`buildBlogPostingJsonLd`, `buildFaqJsonLd`, …). The meta-adapters reshape that into
your framework's head API:
* **Next.js** → `generateOpinlyMetadata()` returns a Next `Metadata` object; `OpinlyJsonLd`
renders the `
{{ post.title }}
```
`opinlyHead({ resolved, config, jsonLd? })` returns a plain object with `title`, `meta`, and
`script` entries — exactly the shape `useHead()` expects. `@opinly/nuxt` also re-exports the
JSON-LD builders (`buildBlogPostingJsonLd`, `buildFaqJsonLd`, …) so you don't need to import
`@opinly/shared` separately for those.
## Notes [#notes]
* `` renders Vue vnodes via Vue's `h()` — no React anywhere.
* Style the rendered body with your own classes or the `classNames` prop — see
[Rendering](/docs/reference/rendering).
### Images [#images]
Point `imagesPrefix` straight at the CDN — images load directly, no proxy or build config:
```ts
const config = {
imagesPrefix: 'https://cdn.opinly.ai/REPLACE-ME-xxxxxxxx', // your 21-char CDN namespace
// …siteUrl, blogPrefix, siteName
}
```
For automatic resizing/format optimization, add [`@nuxt/image`](https://image.nuxt.com) and render
with `` (it has a built-in CDN image provider). Opinly serves images by `fileKey`, so the
absolute URL works in dev and production without a same-origin rewrite.
---
# React (https://opinly.ai/docs/frameworks/react)
`@opinly/react` is a plain React renderer with no Next.js dependency. Use it in Vite, Remix,
Astro React islands, or anywhere React runs. (On Next.js, add [`@opinly/next`](/docs/frameworks/nextjs)
on top for image rewrites + metadata helpers.)
## Install [#install]
```bash
pnpm add @opinly/backend @opinly/react
```
```bash
npm i @opinly/backend @opinly/react
```
```bash
yarn add @opinly/backend @opinly/react
```
## Fetch + render [#fetch--render]
Fetch on the server (or in a server-side data loader) so your API key never reaches the browser.
```tsx
import { createOpinlyClient } from '@opinly/backend'
import { OpinlyContent } from '@opinly/react'
const opinly = createOpinlyClient() // reads OPINLY_API_KEY
export async function Post({ slug }: { slug: string }) {
const post = await opinly.post(slug)
if (!post) return null
return (
{post.title}
)
}
```
## Customize rendering [#customize-rendering]
`` accepts `classNames` (per-node-type CSS classes) and `components` (override how
a node type renders — e.g. swap in your own `` or a router ``):
```tsx
,
}}
/>
```
See [Rendering](/docs/reference/rendering) for the full node/mark coverage and the
`components` contract.
## SEO [#seo]
There's no React-specific SEO adapter — use `@opinly/shared`'s framework-neutral builders
(`buildMetadata`, `buildBlogPostingJsonLd`) and feed them into your app's head management
(React Helmet, Remix `meta`, etc.). See [SEO](/docs/reference/seo).
---
# SvelteKit (https://opinly.ai/docs/frameworks/sveltekit)
In SvelteKit you fetch content in a server `load` function, render it with `@opinly/svelte`, and
add SEO with `@opinly/sveltekit`'s `` component (which writes into ``).
## Install [#install]
```bash
pnpm add @opinly/backend @opinly/svelte @opinly/sveltekit @opinly/shared
```
```bash
npm i @opinly/backend @opinly/svelte @opinly/sveltekit @opinly/shared
```
```bash
yarn add @opinly/backend @opinly/svelte @opinly/sveltekit @opinly/shared
```
Set `OPINLY_API_KEY` in your environment. Fetching in `+page.server.ts` keeps it server-only.
## Fetch in a server load [#fetch-in-a-server-load]
Posts are flat, so a post lives at a single-segment route (`[slug]`). The index,
category archives, and authors get their own routes (`/blog/+page.server.ts`,
`/blog/category/[slug]`, `/blog/authors/[...]`) calling `posts()`/`categories()`/
`author()` — `params.slug` here is always one segment.
```ts
// src/routes/blog/[slug]/+page.server.ts
import { createOpinlyClient } from '@opinly/backend'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params }) => {
const opinly = createOpinlyClient({ apiKey: process.env.OPINLY_API_KEY })
const post = await opinly.post(params.slug) // flat, single-segment post slug
const resolved = post ? { type: 'post' as const, data: post } : { type: 'not-found' as const }
return { resolved }
}
```
> Migrating from category-nested URLs? Add a `[category]/[slug]` route whose server
> load calls `redirect(308, ...)` to the flat post path, so old links resolve.
## Render + SEO [#render--seo]
```svelte
{#if data.resolved.type === 'post'}
{data.resolved.data.title}
{/if}
```
## Images [#images]
Point `imagesPrefix` straight at the CDN (`https://cdn.opinly.ai/`) — images load
directly from the absolute URL, with no proxy or build config needed. If you'd rather serve them
same-origin (e.g. for caching under your domain), add a platform rewrite — Vercel/Netlify
`rewrites`, or a `handle` hook in `src/hooks.server.ts` that proxies `/images/*` to the CDN — and
set `imagesPrefix: '/images'` to match.
`` renders title, meta/OG tags, and any JSON-LD you pass into
``. `` renders the body via `renderToHtml` injected with `{@html}`.
## Notes [#notes]
* Works with Svelte 4 and Svelte 5 (runes).
* Style the body with your own classes or the `classNames` prop — see
[Rendering](/docs/reference/rendering).
---
# Vue (https://opinly.ai/docs/frameworks/vue)
`@opinly/vue` is a plain Vue 3 renderer. Use it in a Vite + Vue app, or anywhere Vue runs. (On
Nuxt, add [`@opinly/nuxt`](/docs/frameworks/nuxt) for the `useHead()` SEO helper.)
## Install [#install]
```bash
pnpm add @opinly/backend @opinly/vue
```
```bash
npm i @opinly/backend @opinly/vue
```
```bash
yarn add @opinly/backend @opinly/vue
```
## Fetch + render [#fetch--render]
Fetch server-side (or in your SSR data layer) to keep the API key private, then render with
``.
```vue
{{ post.title }}
```
`` takes `content`, `config`, and an optional `classNames`. It renders Vue vnodes
via `h()`. See [Rendering](/docs/reference/rendering) for node coverage and styling.
## SEO [#seo]
Use `@opinly/shared`'s neutral builders (`buildMetadata`, `buildBlogPostingJsonLd`) with your
head management of choice (`@vueuse/head`, `useHead`, etc.). See [SEO](/docs/reference/seo).
---
# Quickstart (https://opinly.ai/docs/get-started/quickstart)
This is the shortest path from API key to rendered content. It uses React, but the two core
packages (`@opinly/backend` + a renderer) work the same everywhere.
## 1. Install [#1-install]
```bash
pnpm add @opinly/backend @opinly/react
```
```bash
npm i @opinly/backend @opinly/react
```
```bash
yarn add @opinly/backend @opinly/react
```
## 2. Create a client [#2-create-a-client]
`createOpinlyClient` reads `OPINLY_API_KEY` from the environment by default. Always create it
server-side.
```ts
import { createOpinlyClient } from '@opinly/backend'
export const opinly = createOpinlyClient()
// or be explicit: createOpinlyClient({ apiKey: process.env.OPINLY_API_KEY })
```
## 3. Fetch a route [#3-fetch-a-route]
```ts
const { data: posts } = await opinly.posts({ limit: 12 })
if (posts[0]) {
const post = await opinly.post(posts[0].slug)
// post?.content is your Tiptap JSON (null if not found)
}
```
## 4. Render it [#4-render-it]
```tsx
import { OpinlyContent } from '@opinly/react'
export function Article({ content }) {
return (
)
}
```
That's the whole loop: **fetch → render**. `OpinlyContent` outputs semantic HTML elements you
style yourself (here with Tailwind's `prose`).
## Next steps [#next-steps]
* Wire up the full blog (index, posts, categories, authors, sitemap, RSS, SEO) with your
framework guide: [Next.js](/docs/frameworks/nextjs) · [Nuxt](/docs/frameworks/nuxt) ·
[SvelteKit](/docs/frameworks/sveltekit).
* Customize how nodes render or restyle the body — see [Rendering](/docs/reference/rendering).
* Add metadata + JSON-LD — see [SEO](/docs/reference/seo).
---
# Sign up & get your API key (https://opinly.ai/docs/get-started/sign-up)
Before you install anything, you need an Opinly account and two values: an **API key** and your
**CDN namespace**.
## 1. Create an account [#1-create-an-account]
[Sign up for Opinly](https://opinly.ai/auth/sign-up) and create your company. Generate at least
one blog post in the Content dashboard
so you have something to render.
## 2. Get your credentials [#2-get-your-credentials]
Open Settings → Developers:
* **API key** — click **Create API key**, give it a name, and copy it. It looks like `sk-…`.
You'll only see it once, so store it safely (you'll put it in an environment variable).
* **CDN namespace** — a short identifier shown on the same page. It tells the SDK where your
images live on the Opinly CDN.
```dotenv
# .env
OPINLY_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
Keep your API key on the **server**. The SDK fetches content server-side (in a Server
Component, `load` function, or `useAsyncData`), so the key is never exposed to the browser.
## 3. Pick your framework [#3-pick-your-framework]
You're ready to install. Either follow the [Quickstart](/docs/get-started/quickstart) for the
fastest path, or jump straight to your framework guide:
* [Next.js](/docs/frameworks/nextjs)
* [Nuxt](/docs/frameworks/nuxt)
* [SvelteKit](/docs/frameworks/sveltekit)
* [React](/docs/frameworks/react) · [Vue](/docs/frameworks/vue)
---
# MCP connector (https://opinly.ai/docs/mcp/overview)
The **Opinly MCP connector** is a remote [Model Context Protocol](https://modelcontextprotocol.io)
server that lets Claude (and any MCP-compatible client) work directly with your Opinly workspace —
reading SEO and LLM-visibility data, and drafting, editing, and publishing blog content on your behalf.
It runs against the same data as the [dashboard](https://opinly.ai/dashboard), scoped to the company
you authenticate as.
## Connection details [#connection-details]
The server exposes standard OAuth Protected Resource metadata at
`https://mcp.opinly.ai/.well-known/oauth-protected-resource`, so compliant clients discover the
authorization server automatically. Sign-in is handled by Opinly's identity provider — you log in
with your normal Opinly account and grant the connector access to your workspace. No API keys or
manual credentials are required.
## Connecting from Claude [#connecting-from-claude]
1. Open Claude's **Settings → Connectors** and choose **Add custom connector**.
2. Enter the server URL `https://mcp.opinly.ai/mcp` and save.
3. Claude opens an OAuth window. Sign in with your Opinly account and approve access.
4. The Opinly tools become available in your conversations. Ask Claude to list your companies to
confirm the connection.
Any MCP client that supports remote servers with OAuth 2.0 can connect the same way — point it at
`https://mcp.opinly.ai/mcp`.
## What you can do [#what-you-can-do]
* **SEO research** — traffic, ranked keywords, backlinks, referring domains, keyword and backlink
gaps against competitors, and site audits.
* **LLM visibility** — track how your brand appears in AI answers, compare against competitors, and
manage the prompts you monitor.
* **Content operations** — draft, edit, schedule, publish, and unpublish blog posts; manage authors,
folders, images, and content clusters.
See the full [tool reference](/docs/mcp/tools) for every available tool and whether it reads or
writes data.
## Permissions and safety [#permissions-and-safety]
Every tool declares whether it only reads data or whether it changes something, so your MCP client
can ask for confirmation before anything is created, published, or deleted. Read-only tools (listing
posts, fetching keyword data) never modify your workspace. Write tools (publishing a post, deleting a
cluster) are clearly marked in the [tool reference](/docs/mcp/tools).
## Privacy [#privacy]
The connector accesses only the Opinly workspace data needed to fulfil each request and does not
collect the contents of your conversations. See the
[Opinly privacy policy](https://opinly.ai/privacy) for how data is collected, used, stored, and
shared.
## Support [#support]
Questions or issues with the connector? Contact us at
[support@opinly.ai](mailto:support@opinly.ai).
---
# Tool reference (https://opinly.ai/docs/mcp/tools)
The connector exposes the tools below. Each tool declares its behaviour so your MCP client can
request confirmation before anything changes:
* **Read** — returns data only; never modifies your workspace.
* **Write** — creates or updates data.
* **Destructive** — deletes or removes data, or takes an action that is hard to undo.
---
# @opinly/backend (https://opinly.ai/docs/reference/backend-client)
`@opinly/backend` wraps the [`/v1` REST API](/docs/reference/rest-api) in a small, fully-typed
client. Its TypeScript types are generated from the OpenAPI spec, so they always match the API.
## createOpinlyClient [#createopinlyclient]
```ts
import { createOpinlyClient } from '@opinly/backend'
const opinly = createOpinlyClient({
apiKey?: string, // defaults to process.env.OPINLY_API_KEY
url?: string, // defaults to "https://sdk.opinly.ai"
fetch?: typeof fetch, // inject a custom fetch (e.g. for caching)
})
```
The key is sent as `Authorization: Bearer ` on every request. If no `apiKey` is provided and
`OPINLY_API_KEY` is not set, the call **throws** — so always create the client server-side.
```ts
// Next.js: cache responses, invalidate via webhooks
const opinly = createOpinlyClient({
fetch: (url, init) => fetch(url, { ...init, cache: 'force-cache' }),
})
```
## Methods [#methods]
Each method maps to one endpoint and returns a typed result. There is no `resolve()` — route by
URL in your app and call the matching method. Categories and authors are taxonomy-prefixed, so a
**category archive is `posts({ category })`** and a **single post is `post(slug)`**.
| Method | Returns | Description |
| ------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `posts({ limit?, cursor?, category?, author?, sort? })` | `PostList` | A cursor-paginated page of published posts. Filter by `category`/`author` slug. |
| `post(slug)` | `FullPost \| null` | A single post by its flat, company-unique slug (`string`, e.g. `'my-post'`); `null` if none. |
| `author(slug)` | `AuthorPage` | A single author page (or not-found). |
| `authors()` | `Authors` | All authors with sample posts. |
| `categories()` | `CategorySummary[]` | Categories, each with up to 5 latest posts. |
| `routes()` | `ContentRoute[]` | Every addressable route — `{ type, slug, lastModified }` (bare slugs). Feeds both your sitemap and static generation; shape each with `sitemapUrl`/`routeParams` from `@opinly/shared`. |
| `rss({ limit? })` | `RssItem[]` | Feed items (`{ slug, title, description?, date, categories? }`). |
```ts
const first = await opinly.posts({ limit: 12 })
const next = await opinly.posts({ cursor: first.next_cursor ?? undefined })
const post = await opinly.post('my-post') // FullPost | null
const feed = await opinly.rss({ limit: 50 })
if (post) {
// post.content is your Tiptap JSON
}
```
`post()` returns `null` on a 404; every other method throws on a non-2xx response, and the thrown
`Error` message includes the problem `code` and `detail` from the API (see
[Errors](/docs/reference/rest-api#errors)).
All domain types (`FullPost`, `Post`, `CategorySummary`, `AuthorPage`, `Authors`, `PostList`,
`ContentRoute`, `RssItem`, `ContentNode`, `Problem`, …) are exported from the package for use in
your own components:
```ts
import type { FullPost, Post } from '@opinly/backend'
```
## Webhook types [#webhook-types]
The package also exports the webhook event type for content invalidation:
```ts
import type { OpinlyWebhookEvent } from '@opinly/backend'
// { type: 'content.paths-invalidated'; data: { paths: string[] } }
```
See [Webhooks](/docs/webhooks) for the full handler.
---
# Events & tracking (https://opinly.ai/docs/reference/events)
`track()` on the [`@opinly/backend`](/docs/reference/backend-client) client records a server-side
event. The name can be anything: pick a [standard event](#standard-events) if one fits, or make your
own.
It runs with your secret `sk-` key. Page views, form fills, and identify are already handled in the
browser by the [pixel](/docs/frameworks/nextjs) (publishable `pk-` key), so you don't send those
yourself.
## track [#track]
```ts
import { createOpinlyClient } from '@opinly/backend'
const opinly = createOpinlyClient()
await opinly.track('sign_up', { method: 'google' }, { email: 'user@example.com' })
await opinly.track(
'purchase',
{ value: 49.0, currency: 'USD' },
{ externalEventId: 'order_123', email: 'user@example.com' },
)
```
| Argument | |
| ---------------------- | ---------------------------------------------------------------------------- |
| `event` | The event name, standard or custom. |
| `properties` | Data to attach. Revenue events need `value` (major units) and `currency`. |
| `opts.externalEventId` | Dedup key. Two calls with the same id become one event, so retries are safe. |
| `opts.email` | Ties the event to a visitor. We hash it; the raw email is never stored. |
| `opts.anonId` | The visitor's Opinly id, if you captured it in the browser. |
## Standard events [#standard-events]
Any name works, but these are the ones worth knowing. They line up with GA4, Meta, and Segment, so the
data maps over without translation. Revenue comes from `value` + `currency`; every other property is
stored as-is.
| Event | When to send it | Common properties | Revenue |
| ------------------ | ------------------------- | ---------------------------------------------- | ------- |
| `purchase` | Order completed | `value`, `currency`, `transaction_id`, `items` | yes |
| `refund` | Order refunded | `value`, `currency`, `transaction_id` | yes |
| `add_to_cart` | Item added to cart | `value`, `currency`, `items` | |
| `remove_from_cart` | Item removed | `value`, `currency`, `items` | |
| `begin_checkout` | Checkout started | `value`, `currency`, `items`, `coupon` | |
| `add_payment_info` | Payment details entered | `value`, `currency`, `payment_type` | |
| `view_item` | Product viewed | `value`, `currency`, `items` | |
| `search` | Search run | `search_term` | |
| `sign_up` | Account created | `method` | |
| `login` | User logged in | `method` | |
| `generate_lead` | Lead or contact submitted | `value`, `currency` | |
| `start_trial` | Trial started | `plan`, `value`, `currency` | |
| `subscribe` | Subscription started | `plan`, `interval`, `value`, `currency` | |
Only `purchase` and `refund` become revenue. A `value` on anything else stays a plain property.
Want autocomplete? Import the list:
```ts
import { STANDARD_EVENTS, type StandardEvent } from '@opinly/backend'
```
## trackPurchase [#trackpurchase]
Shorthand for a purchase:
```ts
await opinly.trackPurchase({
orderId: 'order_123',
value: 49.0,
currency: 'USD',
email: 'user@example.com',
})
```
## Reserved names [#reserved-names]
The pixel fires these itself, so the API rejects them with a `400`:
`page_view`, `page_leave`, `click`, `form_submit`, `identify`, `session_start`, `scroll`, and anything
starting with `$`.
---
# Rendering content (https://opinly.ai/docs/reference/rendering)
Your post body is Tiptap JSON (`OpinlyNode`). `@opinly/shared` walks that tree and renders it.
The framework packages (`@opinly/react`, `@opinly/vue`, `@opinly/svelte`) wrap it as an
`` component — that's the surface you'll usually use.
## `` (React / Vue / Svelte) [#opinlycontent-react--vue--svelte]
```tsx
```
* **`config`** — at minimum `imagesPrefix` (where images resolve). Add `siteUrl`/`blogPrefix`/
`siteName` if your nodes need absolute URLs.
* **`classNames`** — attach a class to every node of a type without replacing its markup.
* **`components`** — replace how a node type renders. Each receives `{ node, children }`:
```tsx
(
),
// e.g. route links through next/link, Nuxt , etc.
}}
/>
```
## Node & mark coverage [#node--mark-coverage]
The renderer handles the full content schema out of the box:
* **Block nodes:** `paragraph`, `heading`, `image`, `bulletList`, `orderedList`, `listItem`,
`blockquote`, `codeBlock`, `horizontalRule`, `hardBreak`, and the table family (`table`,
`tableRow`, `tableHeader`, `tableCell`).
* **Marks:** `bold`, `italic`, `strike`, `underline`, `code`, `link`, `textStyle` (color).
Unknown node/mark types are skipped safely. Links are sanitized (`javascript:` and other unsafe
URIs are dropped) and all text is HTML-escaped.
## Lower-level helpers (`@opinly/shared`) [#lower-level-helpers-opinlyshared]
If you're not in React/Vue/Svelte — or you want an HTML string — use the core functions:
```ts
import { renderToHtml, createRenderer } from '@opinly/shared'
// 1. HTML string (RSS, email, plain SSR):
const html = renderToHtml(content, { config: { imagesPrefix: '/images' } })
// 2. Generic element walker — inject your framework's createElement:
const render = createRenderer({
config: { imagesPrefix: '/images' },
renderFn: (type, props, children) => /* React.createElement / h / … */,
})
const elements = render(content)
```
`renderToHtml` is what `@opinly/svelte` uses internally; `createRenderer` is what `@opinly/react`
and `@opinly/vue` wrap.
## Content utilities [#content-utilities]
`@opinly/shared` also exports pure helpers you can use anywhere:
* `imageUrl(fileKey, config)` — build a CDN image URL.
* `extractHeadings(content)` — pull headings for a table of contents.
* `calculateReadingTime(content)` / `countWords(content)`.
* `blogPath`/`blogUrl`, `postPath`/`postUrl`, `categoryPath`/`categoryUrl`, `authorPath`/`authorUrl`
— URL builders (`*Path` = relative, for in-app links; `*Url` = absolute, for canonicals). All
honour `categoryPrefix` / `authorPrefix` from your `OpinlyConfig`.
---
# REST API (/v1) (https://opinly.ai/docs/reference/rest-api)
`@opinly/backend` is a thin client over a versioned REST API. You can call it directly from any
language or runtime if you prefer.
* **Base URL:** `https://sdk.opinly.ai`
* **All routes are under** `/v1/content`
* **OpenAPI spec:** [`/v1/openapi.json`](https://sdk.opinly.ai/v1/openapi.json)
* **Interactive docs:** [`/v1/docs`](https://sdk.opinly.ai/v1/docs) (Scalar)
## Authentication [#authentication]
Send your API key as a **bearer token** in the `Authorization` header on every request:
```bash
curl https://sdk.opinly.ai/v1/content/posts \
-H "Authorization: Bearer sk-…"
```
Your key is scoped to your company, so every request returns only your content. Keep it
server-side.
A missing or invalid key returns **401** as an `application/problem+json` body (see
[Errors](#errors)):
```json
{
"type": "https://opinly.ai/docs/reference/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"code": "UNAUTHORIZED",
"request_id": "9f1c…"
}
```
## Endpoints [#endpoints]
| Method | Path | Description | Params |
| ------ | ---------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `GET` | `/v1/content/posts` | Published posts (cursor-paginated, filterable). | `limit` (1–100, default 12), `cursor`, `category`, `author`, `sort` (`newest`\|`oldest`) |
| `GET` | `/v1/content/post` | A single post by slug (404 if none). | `slug` (the post's slug, e.g. `my-post`) |
| `GET` | `/v1/content/categories` | Categories, each with up to 5 latest posts. | — |
| `GET` | `/v1/content/authors` | All authors with sample posts. | — |
| `GET` | `/v1/content/authors/{slug}` | A single author page. | `slug` (path) |
| `GET` | `/v1/content/routes` | All addressable routes (sitemap + static generation): typed `{ type, slug, lastModified }`, bare slugs. | — |
| `GET` | `/v1/content/rss` | RSS feed items. | `limit` (1–100, default 20) |
There is no single "resolve everything" endpoint — you route by URL and call the matching typed
endpoint. Categories and authors are taxonomy-prefixed (`/blog/category/…`, `/blog/authors/…`), so
a **category archive is just `GET /v1/content/posts?category=`**, and a **single post** is
`GET /v1/content/post?slug=` (returns the post, or **404** if there's none).
## Pagination [#pagination]
`GET /v1/content/posts` is **cursor-paginated**. The response is an envelope:
```ts
interface PostList {
data: Post[]
has_more: boolean
next_cursor: string | null // opaque; pass as ?cursor= for the next page
}
```
Fetch the next page by passing the previous response's `next_cursor`:
```bash
# first page
curl "https://sdk.opinly.ai/v1/content/posts?limit=12" -H "Authorization: Bearer sk-…"
# next page
curl "https://sdk.opinly.ai/v1/content/posts?limit=12&cursor=MTcwMDA…" -H "Authorization: Bearer sk-…"
```
When there are more results, the response also carries an [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288)
`Link: <…>; rel="next"` header. Cursors are opaque — don't parse or construct them.
## Response shapes [#response-shapes]
`GET /v1/content/post` returns a `FullPost` (200) or a problem document (404).
`FullPost` carries the body and everything needed to render a post page:
```ts
interface FullPost {
content: object // Tiptap JSON (OpinlyNode)
title: string
slug: string
description: string
metaTitle: string | null
metaDescription: string | null
titleFile: { fileKey: string | null; altText: string | null; title: string | null; caption: string | null } | null
images: { fileKey: string | null; altText: string | null; title: string | null; caption: string | null }[]
firstPublishedAt: string // ISO 8601
modifiedAt: string
author: { name: string; slug: string; fileKey: string | null; bio: string | null } | null
faqs: { question: string; answer: string }[] | null
category: { slug: string; name: string; description: string } | null
}
```
`Post` (the lightweight "card" used in lists) and `Category`, `Sitemap`, `Rss` shapes are all
defined in the OpenAPI spec — the client's TypeScript types are generated from it, so they can't
drift. See the [client reference](/docs/reference/backend-client) for the typed methods.
## Errors [#errors]
Every non-2xx response is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json`:
```json
{
"type": "https://opinly.ai/docs/reference/errors/validation-error",
"title": "Invalid request",
"status": 400,
"detail": "limit: must be less than or equal to 100",
"instance": "/v1/content/posts",
"code": "VALIDATION_ERROR",
"request_id": "9f1c…"
}
```
* `code` is a stable, machine-readable identifier (`UNAUTHORIZED`, `VALIDATION_ERROR`,
`INVALID_CURSOR`, `NOT_FOUND`, `INTERNAL_ERROR`).
* `request_id` is echoed in the `X-Request-Id` response header on every request — quote it when
contacting support. Send your own `X-Request-Id` to correlate requests end to end.
---
# SEO & structured data (https://opinly.ai/docs/reference/seo)
SEO has two parts: **metadata** (title, description, canonical, Open Graph) and **structured
data** (schema.org JSON-LD). `@opinly/shared` builds both in a framework-neutral form; the
meta-adapters reshape them for your framework.
## Metadata builders (`@opinly/shared`) [#metadata-builders-opinlyshared]
```ts
import { buildMetadata } from '@opinly/shared'
const meta = buildMetadata(resolved, config)
// → { title, description?, canonicalUrl?, ogImage?, ogType?, authors?, publishedTime?, modifiedTime? }
```
`buildMetadata` takes a resolved route (a `SeoResolved` — a `{ type, data }` tagged with its kind,
e.g. `{ type: 'post', data: await opinly.post(slug) }` or the result of `opinly.author()`) plus
your `OpinlyConfig` and returns neutral `OpinlyMeta`. Map that onto whatever head API your
framework uses.
## JSON-LD builders (`@opinly/shared`) [#json-ld-builders-opinlyshared]
Each returns a plain schema.org object (`@context` included) ready to serialize:
| Builder | Schema |
| -------------------------------------- | ---------------- |
| `buildBlogPostingJsonLd(post, config)` | `BlogPosting` |
| `buildFaqJsonLd(faqs)` | `FAQPage` |
| `buildBreadcrumbJsonLd(items)` | `BreadcrumbList` |
| `buildPersonJsonLd(author, config)` | `Person` |
| `buildCollectionJsonLd(collection)` | `CollectionPage` |
## Per-framework adapters [#per-framework-adapters]
`@opinly/next` gives you a ready Next `Metadata` object and a JSON-LD `
```
No dedicated adapter — use the neutral builders with your head management (React Helmet, Remix
`meta`, `@vueuse/head`, …):
```ts
import { buildMetadata, buildBlogPostingJsonLd } from '@opinly/shared'
const meta = buildMetadata(resolved, config)
const jsonLd = buildBlogPostingJsonLd(post, config)
// render , , and