# 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 ` ``` That's the whole install. `data-key` is the only required attribute. ```bash pnpm add @opinly/next ``` Render `` in your root layout so it loads on every route. It uses `next/script` under the hood, so it won't block your page. ```tsx // app/layout.tsx import { OpinlyPixel } from '@opinly/next/pixel' export default function RootLayout({ children }) { return ( {children} ) } ``` SPA navigations are handled for you — the pixel patches the History API, so an App Router route change fires a `page_view` without any extra wiring. ```bash pnpm add @opinly/react ``` Render it once, near the root of your app (Vite, Remix, Astro islands — anything). ```tsx import { OpinlyPixel } from '@opinly/react/pixel' export function App() { return ( <> ) } ``` ```bash pnpm add @opinly/vue ``` Install it as a plugin: ```ts // main.ts import { OpinlyPixel } from '@opinly/vue' app.use(OpinlyPixel, { writeKey: 'REPLACE_WITH_YOUR_PK_KEY', host: 'https://static.opinly.ai', }) ``` ```bash pnpm add @opinly/nuxt ``` Call `useOpinlyPixel` once in your app root: ```vue ``` ```bash pnpm add @opinly/sveltekit ``` Add the component to your root layout: ```svelte ``` ```bash pnpm add @opinly/svelte ``` ```svelte ``` Any bundler, any framework. `loadOpinlyPixel` injects the script tag for you and is safe to call during SSR (it no-ops when there's no `document`). ```ts import { loadOpinlyPixel } from '@opinly/shared/pixel' loadOpinlyPixel({ writeKey: 'REPLACE_WITH_YOUR_PK_KEY', host: 'https://static.opinly.ai', }) ``` If you'd rather not add a dependency at all, use the plain script tag from the **HTML** tab. `host` is always `https://static.opinly.ai`. It serves both the script and the endpoint the script reports to, which is why you never need to configure an API host separately. ## Confirm it's working [#confirm-its-working] Load a page on your site, then check either of these: **In your browser.** Open the Network tab and reload. You should see `p.js` load, followed by a `POST` to `https://static.opinly.ai/track`. * **`p.js` loads but no `POST` follows** — your `data-key` is missing. The script bails silently without one. * **The `POST` comes back `401`** — the key is wrong. Check you copied your publishable `pk-` key and not your secret `sk-` key. **In the dashboard.** Open **Analytics → Customers**. Your visit appears in the **Visitors** table within a few seconds. Check the **Visitors** table, not the **Conversions** card. Page views are aggregated rather than listed as events — see [What gets captured](/docs/analytics/what-gets-captured#where-events-land) — so a healthy pixel shows an empty Conversions card until your first conversion fires. That's expected. ## Next steps [#next-steps] * See exactly [what gets captured](/docs/analytics/what-gets-captured) automatically, and what you have to send yourself. * Tie visitors to real people with [Identify visitors](/docs/analytics/identify). * Attribute a server-side purchase back to a browser visit with [Linking server events](/docs/analytics/server-events). --- # Pixel reference (https://opinly.ai/docs/analytics/reference) ## window\.opinly [#windowopinly] Available once the script loads. Four members, that's the whole surface. | Member | Signature | | | ---------- | ------------------------------------- | -------------------------------------------------------------------------------------------------- | | `anonId` | `string` | The visitor's anonymous ID. Your join key for [server-side events](/docs/analytics/server-events). | | `track` | `(event, properties?, opts?) => void` | Record an event. | | `identify` | `({ email, userId? }) => void` | Link this visitor to a person. Ignored without `email`. | | `page` | `() => void` | Fire a `page_view` manually. | ### track [#track] ```js window.opinly.track('purchase', { value: 49.0, currency: 'USD' }, { externalEventId: 'order_123', }) ``` | Argument | | | | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `event` | `string` | The event name. See [where events land](/docs/analytics/what-gets-captured#where-events-land) for which names become conversions. | | `properties` | `object` | Any data to attach. On `purchase` and `refund`, `value` + `currency` become revenue; on any other event `value` stays a plain property. | | `opts.externalEventId` | `string` | Dedup key. Two calls with the same ID become one event — so retries are safe, and a server-side call with the same ID merges rather than duplicating. | ### identify [#identify] ```js window.opinly.identify({ email: 'user@example.com', userId: 'usr_123' }) ``` The first identify wins; a later one with a different email won't overwrite it. See [Identify visitors](/docs/analytics/identify). ### opinly:ready [#opinlyready] The pixel fires an `opinly:ready` event on `window` once it's live. You rarely need it — the framework packages queue calls made before load and flush them for you — but it's there if you're working with the raw script tag. It fires **once**, synchronously, the moment the pixel is live, and does not replay for listeners added later. Since the snippet loads `async`, a listener you register afterwards would never run — so check first, then subscribe: ```js function onReady() { window.opinly.identify({ email: currentUser.email }) } if (window.opinly) onReady() else window.addEventListener('opinly:ready', onReady, { once: true }) ``` ## Script tag attributes [#script-tag-attributes] ```html ``` | Attribute | | | | --------------- | ------------ | ----------------------------------------------------------------------------------------------------------------- | | `data-key` | **required** | Your publishable `pk-` key. Without it the script does nothing at all — silently. | | `data-api-host` | optional | Where to send events. Defaults to the origin the script was served from, which is correct unless you're proxying. | ### data-opinly-no-capture [#data-opinly-no-capture] Put it on any element to exclude that element and its descendants from click, form, and identify capture. ```html
...
``` ## Package exports [#package-exports] The import path differs by framework — check this table rather than guessing. | Package | Import from | Exports | | ------------------- | ---------------------- | ----------------------------------- | | `@opinly/next` | `@opinly/next/pixel` | `OpinlyPixel`, `useOpinly` | | `@opinly/react` | `@opinly/react/pixel` | `OpinlyPixel`, `useOpinly` | | `@opinly/vue` | `@opinly/vue` | `OpinlyPixel`, `useOpinly` | | `@opinly/nuxt` | `@opinly/nuxt` | `useOpinlyPixel`, `useOpinly` | | `@opinly/svelte` | `@opinly/svelte` | `OpinlyPixel`, `getOpinlyPixel` | | `@opinly/sveltekit` | `@opinly/sveltekit` | `OpinlyPixel`, `getOpinlyPixel` | | `@opinly/shared` | `@opinly/shared/pixel` | `loadOpinlyPixel`, `getOpinlyPixel` | All of them take the same two options: | Option | | | | ---------- | ------------ | --------------------------- | | `writeKey` | **required** | Your publishable `pk-` key. | | `host` | **required** | `https://static.opinly.ai` | `useOpinly()` — or `getOpinlyPixel()` in Svelte and SvelteKit, which don't ship a hook — returns a handle with the same three methods as `window.opinly`: `track`, `identify` and `page`. They're queued, so they're safe to call before the pixel has loaded and safe during SSR (they no-op until there's a browser). The anonymous ID is the one difference: the handle exposes it as a `getAnonId()` method, not an `anonId` property. ## Browser storage [#browser-storage] The pixel sets no cookies. Everything lives in the browser's own storage, on your domain. | Key | Where | What | | -------------------------- | --------------- | ----------------------------------------------- | | `_opinly_anon` | Local storage | The anonymous visitor ID | | `_opinly_utm` | Local storage | The campaign that last referred this visitor | | `_opinly_first_visit_sent` | Local storage | Marks the first visit as recorded | | `opinly_identified_email` | Local storage | Stops the same email being identified twice | | `_opinly_session` | Session storage | The current session; ends after 30 minutes idle | If storage is unavailable — private browsing, or a locked-down browser — the pixel degrades quietly rather than throwing. The visitor is counted, but each visit looks new. --- # Linking server events to browser visitors (https://opinly.ai/docs/analytics/server-events) The browser knows where a visitor came from. Your server knows what they bought. Attribution is the business of connecting those two facts — and a server-side event that arrives without a visitor link can only ever tell you that a sale happened, never which campaign earned it. Opinly is built to keep that link intact. This page is the contract: follow it and every server-recorded sale traces back to the ad click that started it. ## Why send anything server-side at all [#why-send-anything-server-side-at-all] Track purchases in the browser and you'll lose some. A tab closes mid-redirect, an ad blocker eats the request, the payment settles hours later on a webhook when nobody's looking. For a revenue number, "mostly right" isn't right. Server-side events don't have those problems. What they don't have is identity. | | Client side | Server side | | ---------------------------- | ---------------------- | ---------------------------------- | | Knows the campaign | Yes — it saw the click | No | | Survives ad blockers | No | Yes | | Fires when the tab is closed | No | Yes | | Best for | Behaviour, attribution | Money, anything that must be exact | The answer is to send both and let them merge. That's supported directly — see [Sending both](#sending-both). ## The join key [#the-join-key] Every browser visitor has an anonymous ID. **That ID is the join key**, and getting it onto your server-side call is the whole job. A server event finds its visitor in one of three ways, best first: ### You pass `anonId` — exact [#you-pass-anonid--exact] The event attaches to precisely that visitor. Full attribution, no guessing. Always prefer this. ### You pass `email` — inferred [#you-pass-email--inferred] Opinly hashes it and looks for a visitor who identified with the same address. If one exists, the event inherits that visitor's ID and original campaign — so a purchase your server reports with nothing but an email still lands on the right visit. If the same address has identified on several devices, the event inherits the campaign of the visitor record created most recently — the device whose *first* visit is newest, not the one they used last. If nobody matches, it falls through to the next case. ### You pass neither — unattributed [#you-pass-neither--unattributed] The event is still recorded, with real revenue, against a synthetic visitor. It counts toward your totals and shows up as **direct**. Nothing is lost except the attribution — which is the entire reason you're here. **Send at least one of `anonId` or `email` on every server-side call.** Neither is enforced — the event will be accepted, and it will silently be worth less than it should be. ## Getting the anonymous ID to your server [#getting-the-anonymous-id-to-your-server] Read it in the browser and pass it along with a request you're already making. There's no magic here and that's the point — it's your data, moving through your own API. ```js // browser await fetch('/api/checkout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cart, anonId: window.opinly.anonId, }), }) ``` ```ts // your server import { createOpinlyClient } from '@opinly/backend' const opinly = createOpinlyClient() const order = await charge(req.body.cart) await opinly.track('purchase', { value: order.total, currency: 'USD', }, { externalEventId: order.id, anonId: req.body.anonId, email: order.email, }) ``` Sending both `anonId` and `email` is the belt-and-braces move: the ID attributes exactly, and the email is the fallback for when the ID never reached us — a blocked first-visit beacon, a lost value, or an order placed with no ID captured at all. If a checkout spans a redirect (a hosted payment page, say), stash the anon ID on the order record when the checkout starts, and read it back when the webhook confirms payment. The ID is just a string — persist it wherever the order lives. ## Sending both [#sending-both] You can safely record the same order from the browser *and* from your server. Give both calls the same `externalEventId` and they collapse into a single event. The two ids must match **exactly** — same string, same case. `order_123` and `123` are two different orders as far as dedup is concerned, and you'd count the revenue twice. Generate the id once, from your real order number, and send that same string from both sides. (If you use `trackPurchase`, its `orderId` is the dedup key — pass your order number there and use the same value as the browser's `externalEventId`.) ```js // browser, on the thank-you page window.opinly.track('purchase', { value: 49.0, currency: 'USD' }, { externalEventId: 'order_123', }) ``` ```ts // server, when the payment settles await opinly.track('purchase', { value: 49.0, currency: 'USD' }, { externalEventId: 'order_123', email: order.email, }) ``` One order, one event, best of both — and two rules decide the merge: * **The server's revenue wins.** Your backend is the authority on what was actually charged, so its figure overwrites whatever the browser reported. * **A real anonymous ID always beats a synthetic one.** If the browser got there first with a genuine ID, a later server event that couldn't identify the visitor will *not* overwrite it. The net effect: the server can only ever *improve* attribution, never degrade it. That's the property to hold on to. It means you can add server-side tracking to a working pixel setup without risking the data you already have. ## WooCommerce does this for you [#woocommerce-does-this-for-you] If you connect WordPress from the dashboard, this is already wired up. WooCommerce sends each order to Opinly server-side, keyed on its own order ID, and it merges with the browser's version by exactly the rules above. You don't need to write any of the code on this page — see [WordPress](/docs/analytics/wordpress). ## Checking it worked [#checking-it-worked] Open **Analytics → Conversions** and look at the source breakdown for the order. * Attributed to a campaign → the join worked. * Attributed to **direct** → the event arrived without a usable `anonId` or `email`, or the email didn't match any identified visitor. The revenue is right; the attribution isn't. A run of unexpected "direct" revenue almost always means the anon ID isn't surviving the trip to your backend. Log what you're sending and confirm it isn't `undefined`. --- # What gets captured (https://opinly.ai/docs/analytics/what-gets-captured) Install the pixel and a lot happens without any further code. This page is the complete list — read it before you start sending custom events, because you may not need to. ## Captured automatically [#captured-automatically] Every event below fires on its own once the script is on the page. | Event | When it fires | What it carries | | ------------- | ------------------------------------------------- | ----------------------------------------------------- | | *First visit* | Once per browser, ever | Landing URL, UTM tags, referrer, ad click ID | | `page_view` | Every page load, and every SPA route change | Page path, title, referrer, session ID | | `page_leave` | Leaving or backgrounding a page | Time on page, scroll depth | | `click` | Links, buttons, and anything with `role="button"` | Element tag, text, href, whether the link is external | | `form_submit` | Any form submission | Field **names** and count — never the values | | `identify` | An email is typed into a recognisable email field | The email address, stored only as a hash | Single-page apps work without configuration. The pixel patches the History API, so a client-side route change fires a `page_view` the same as a full page load. `page_view`, `click`, `form_submit` and your own `track()` calls also carry a session ID (sessions end after 30 minutes of inactivity) and basic page context: path, title, hostname, screen and viewport size, language, and timezone. `identify` and `page_leave` are deliberately leaner. Every event carries the visitor's anonymous ID. ### What is never captured [#what-is-never-captured] * **The values typed into forms.** `form_submit` carries only field names and a count. The single exception is an email address: the pixel reads a recognisable email field so it can identify the visitor, and sends it to be hashed — see [Identify visitors](/docs/analytics/identify). Nothing else you type is ever read. * **Password and hidden fields.** Excluded entirely, not even by name. * **IP addresses.** The pixel derives a two-letter country code at the edge and stores that. The address itself is never written down. * **Cookies.** The pixel sets none. The anonymous ID is a random UUID in the browser's own local storage, scoped to your domain. ### Opting an element out [#opting-an-element-out] Add `data-opinly-no-capture` to any element to exclude it and everything inside it from click, form, and identify capture. ```html
``` ## Attribution: how a visitor's source is decided [#attribution-how-a-visitors-source-is-decided] On the first visit the pixel stores the campaign that brought the visitor in — UTM tags, referrer, and any ad click ID (`gclid`, `fbclid`, and nine others). That stored source is **overwritten whenever the visitor arrives on a fresh campaign.** If they come back through a new ad, the new campaign wins. A visit with no campaign at all leaves the stored one untouched, so an organic return visit won't wipe the ad that earned them. **Two models are in play, and it's worth knowing which you're reading.** * **Events sent from the browser** carry that stored source, so they're **last-non-direct** — the same default GA uses. A returning visitor's purchase is credited to the most recent campaign that referred them. * **Your visitor, campaign and country reports** are **first-touch**. A visitor's own record keeps the campaign from their very first visit and never changes. * **Events sent from your server** inherit that visitor record, so they're first-touch too — a server-reported purchase is credited to the campaign that first brought the visitor in. ## Sending your own events [#sending-your-own-events] Anything not in the table above, you send yourself with [`window.opinly.track()`](/docs/analytics/reference#track). Purchases are the common case: ```js window.opinly.track('purchase', { value: 49.0, currency: 'USD' }, { externalEventId: 'order_123', }) ``` `externalEventId` is a dedup key — send the same order twice and it collapses to one event. It's also what lets a server-side confirmation of the same order merge with the browser's version. See [Linking server events](/docs/analytics/server-events). ## Where events land [#where-events-land] Opinly keeps two stores: conversions are kept row by row so you can act on them, and high-volume behaviour is aggregated so your reports stay fast. Here's what goes where. **These events are stored individually** and appear in your dashboard immediately: `purchase` · `refund` · `sign_up` · `add_to_cart` · `generate_lead` · `form_submit` All of them count as conversions except `refund`, which is stored for its negative revenue only. `signup` and `lead` are accepted too — they're the original names, kept working permanently. They are stored exactly as you send them, so pick one spelling and stay with it or your reports will split across both. Prefer `sign_up` and `generate_lead`, which match the [standard events](/docs/reference/events#standard-events) the server-side SDK uses. `form_submit` is the one conditional entry. It's stored individually if your conversion goal is form tracking — or if you haven't picked a goal yet, which is the default. If your goal is purchases, form submissions are aggregated like page views — see [Conversion goals](/docs/analytics/conversion-goals). **Everything else the pixel captures in the browser** — page views, clicks, page leaves, and any custom event name of your own — is aggregated rather than stored row by row. It powers your traffic and sources reports, which refresh hourly, and your pages report, which refreshes daily. Events you send from your server with [`track()`](/docs/reference/events) are the exception: they are always stored individually, whatever you name them — except the names the pixel already collects (`page_view`, `click`, `form_submit`, `identify` and a few others), which the API rejects with a `400`. They're deliberate and low-volume, so they don't need aggregating — but a custom name still won't count as a conversion. Two consequences worth knowing: 1. **Your dashboard's event list will not show page views.** That's by design, not a broken install. To confirm the pixel works, look at **Visitors**. 2. **A custom event name won't appear as a conversion.** Use one of the names above if you want it counted. Conversion goals let you choose *which form submissions* count — they can't promote a custom event name. See [Conversion goals](/docs/analytics/conversion-goals). `refund` is stored with its negative value and is not counted as a conversion. Revenue reports show **gross** revenue from purchases, so refunds sit alongside them rather than netting off. --- # WordPress & WooCommerce (https://opinly.ai/docs/analytics/wordpress) WordPress is the one platform where you don't install anything by hand. ## Connecting [#connecting] In the dashboard, go to **Analytics** and connect WordPress. Opinly installs the [Opinly Analytics plugin](https://wordpress.org/plugins/opinly-analytics/) into your site, configures it with your key, and activates it. The pixel starts reporting immediately. If your host blocks remote plugin installs, the dashboard falls back to giving you a connect key to paste into the plugin's settings — install the plugin from your own WordPress admin, paste the key, done. ## What you get [#what-you-get] * The pixel on every page, so page views, clicks, forms and campaigns are tracked. * **WooCommerce orders reported server-side**, keyed on the order ID. That's the reliable revenue path — it doesn't depend on the buyer's browser reaching a thank-you page. * Consent handling via the WordPress Consent API — when a consent plugin that supports it is active, Opinly follows its decision for the `statistics` category. With no such plugin installed the pixel tracks by default; a setting on the plugin turns that off. Note the WooCommerce order webhook is your own sales record and reports regardless. There's no snippet to paste and no code to write. Everything on [Install the pixel](/docs/analytics/install) is for other platforms. ## Reference [#reference] The plugin's own listing is the reference for its settings, consent behaviour, and the exact data it sends: **[wordpress.org/plugins/opinly-analytics](https://wordpress.org/plugins/opinly-analytics/)**. The rest of these docs still apply — [what gets captured](/docs/analytics/what-gets-captured) and [identifying visitors](/docs/analytics/identify) describe the same pixel. --- # Next.js (https://opinly.ai/docs/frameworks/nextjs) This guide builds a complete blog on the Next.js **App Router**. You own the page UI; the SDK provides data (`@opinly/backend`), the content renderer (`@opinly/react`), and the Next.js glue (`@opinly/next`). ## Install [#install] ```bash pnpm add @opinly/backend @opinly/react @opinly/next @opinly/shared ``` ```bash npm i @opinly/backend @opinly/react @opinly/next @opinly/shared ``` ```bash yarn add @opinly/backend @opinly/react @opinly/next @opinly/shared ``` ## Configure `next.config` [#configure-nextconfig] `withOpinlyConfig` injects the SDK's environment variables and adds an image rewrite so your post images are served from the Opinly CDN under your own domain. ```ts // next.config.ts import type { NextConfig } from 'next' import { withOpinlyConfig } from '@opinly/next' const nextConfig: NextConfig = { // ...your existing config } export default withOpinlyConfig({ blogPath: '/blog', // where your blog lives (must match your route) imagesPath: '/images', // local path images are rewritten from companyName: 'Acme', // used in metadata cdnNamespace: 'REPLACE-ME-xxxxxxxx', // 21 chars, from Settings → Developers siteUrl: 'https://acme.com', // no trailing slash })(nextConfig) ``` | Option | Notes | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `blogPath` | Must match the route segment your blog renders under (e.g. `/blog`). | | `imagesPath` | Local prefix rewritten to the CDN. Must not collide with your own assets. | | `cdnNamespace` | Exactly 21 characters. From Settings → Developers. | | `siteUrl` | Absolute site URL, used for canonical URLs and JSON-LD. | | `categoryPrefix?` | URL segment category archives live under, relative to `blogPath`. Default `"category"` (`/blog/category/nutrition`); set `""` for bare `/blog/nutrition`. Applies to archives only — post permalinks are never prefixed. Must match your routing. | | `authorPrefix?` | URL segment authors live under, relative to `blogPath`. Default: `"authors"` (`/blog/authors/jane`). | | `unoptimizedImages?` | Set `true` to skip Next image optimization. | Add your API key to the environment: ```dotenv # .env OPINLY_API_KEY="sk-…" ``` ## Create the client [#create-the-client] ```ts // clients/opinly.ts import { createOpinlyClient } from '@opinly/backend' // Picks up OPINLY_API_KEY from the env. `force-cache` puts responses in the data // cache; the tags are what let a webhook invalidate exactly what changed rather // than guessing (see Operations → Webhooks). export const opinly = createOpinlyClient({ fetch: (url, init) => fetch(url, { ...init, cache: 'force-cache', next: { tags: ['opinly'] } }), }) ``` ## The blog route [#the-blog-route] A single optional-catch-all route renders every blog page. Because the taxonomy is prefixed, you route by the URL's **first segment** to the matching typed endpoint — a category archive is just `posts({ category })`, and `post(slug)` fetches the single post. A small `loadRoute` helper keeps the page and `generateMetadata` in sync (Next dedupes the cached fetches, so calling it twice is free). ```tsx // app/blog/[[...slug]]/page.tsx import type { ResolvingMetadata } from 'next' import { notFound } from 'next/navigation' import { generateOpinlyMetadata, opinlyConfig } from '@opinly/next' import type { SeoResolved } from '@opinly/shared' import { opinly } from '@/clients/opinly' export const revalidate = 3600 const categoryPrefix = opinlyConfig.categoryPrefix ?? 'category' const authorPrefix = opinlyConfig.authorPrefix ?? 'authors' type BlogPageProps = { params: Promise<{ slug?: string[] }> } const loadRoute = async (slug: string[]) => { if (slug.length === 0) { const [posts, categories] = await Promise.all([opinly.posts({ limit: 12 }), opinly.categories()]) return { type: 'home' as const, data: { posts: posts.data, categories } } } if (slug[0] === categoryPrefix && slug[1]) { const [categories, list] = await Promise.all([opinly.categories(), opinly.posts({ category: slug[1] })]) const meta = categories.find((c) => c.slug === slug[1]) if (!meta) return { type: 'not-found' as const } return { type: 'category' as const, data: { ...meta, name: meta.title, posts: list.data } } } if (slug[0] === authorPrefix) { const authorSlug = slug[1] if (!authorSlug) return { type: 'authors' as const, data: (await opinly.authors()).data } const author = await opinly.author(authorSlug) return author.type === 'author' ? { type: 'author' as const, data: author.data } : { type: 'not-found' as const } } // Posts are flat: a single-segment slug. Anything deeper isn't a post route. if (slug.length !== 1) return { type: 'not-found' as const } const post = await opinly.post(slug[0]) // a single post by its flat slug, or null return post ? { type: 'post' as const, data: post } : { type: 'not-found' as const } } // Map the route to the neutral SeoResolved buildMetadata understands. const toSeo = (route: Awaited>): SeoResolved => route.type === 'post' || route.type === 'category' || route.type === 'author' ? { type: route.type, data: route.data } : { type: route.type } export const generateMetadata = async (props: BlogPageProps, parent: ResolvingMetadata) => { const { slug } = await props.params return generateOpinlyMetadata(toSeo(await loadRoute(slug ?? [])), parent) } export default async function BlogPage(props: BlogPageProps) { const { slug } = await props.params const route = await loadRoute(slug ?? []) switch (route.type) { case 'home': return case 'post': return case 'category': return case 'author': return case 'authors': return default: notFound() } } ``` `generateOpinlyMetadata` takes the **already-resolved** data (not the client), so there's no second fetch in `generateMetadata`. ## Render the post body [#render-the-post-body] Use `` from `@opinly/react`. Derive the render config from `opinlyConfig` (which `@opinly/next` populates from the env vars you configured above), so images resolve correctly. ```tsx // components/post-content.tsx import { OpinlyContent } from '@opinly/react' import { opinlyConfig } from '@opinly/next' import type { OpinlyNode } from '@opinly/shared' const config = { imagesPrefix: opinlyConfig.imagesPrefix, siteUrl: opinlyConfig.siteUrl, blogPrefix: opinlyConfig.blogPrefix, siteName: opinlyConfig.siteName, } export function PostContent({ content }: { content: OpinlyNode }) { return (
) } ``` To swap in `next/image` or `next/link` for specific nodes, pass `components` — see [Rendering](/docs/reference/rendering). ## Structured data (JSON-LD) [#structured-data-json-ld] ```tsx import { OpinlyJsonLd, buildBlogPostingJsonLd, buildFaqJsonLd } from '@opinly/next' // inside your post component, with the resolved FullPost: {post.faqs?.length ? : null} ``` ## Sitemap [#sitemap] ```ts // app/sitemap.ts import type { MetadataRoute } from 'next' import { buildSitemapEntries } from '@opinly/shared' import { opinlyConfig } from '@opinly/next' import { opinly } from '@/clients/opinly' export const revalidate = false export default async function sitemap(): Promise { // One call feeds both the sitemap and generateStaticParams. buildSitemapEntries // shapes each typed route into an absolute URL — posts flat, categories/authors // prefixed per your config — so no manual URL building. const routes = await opinly.routes() return buildSitemapEntries(routes, opinlyConfig).map((e) => ({ url: e.url, lastModified: new Date(e.lastModified), })) } ``` ## RSS [#rss] ```ts // app/blog/rss.xml/route.ts import { opinly } from '@/clients/opinly' export const revalidate = false export async function GET() { const items = await opinly.rss({ limit: 50 }) // build your XML from items ({ slug, title, description, date, categories }) // … return new Response(xml, { headers: { 'Content-Type': 'application/xml' } }) } ``` ## Keep content fresh [#keep-content-fresh] Content is cached (`force-cache` on the fetches, `revalidate: false` on the routes), so a webhook tells you what to invalidate instead of you guessing. There are two caches and they need different tools: * **`revalidateTag('opinly', { expire: 0 })`** for the data cache — the tag your client put on every fetch. Works whether the route is static or dynamic. On Next 16+ that second argument is required and it matters: `{ expire: 0 }` drops the cached responses now, whereas a named profile such as `'max'` keeps serving them for up to a year while refreshing behind them. * **`revalidatePath`** for the rendered routes that changed. Use both. On a self-hosted/OpenNext deployment `revalidatePath` is a silent no-op for routes that render dynamically, because the tag→path mapping is only seeded for prerendered routes at build time — so path invalidation alone can leave stale pages with no error to tell you. See [Webhooks](/docs/webhooks) for the handler. ## Recap [#recap] `withOpinlyConfig` → `createOpinlyClient` → route by URL (`posts()` / `categories()` / `post()` / `author()`) + a `switch` → `` for the body → `generateOpinlyMetadata` + `OpinlyJsonLd` for SEO → `routes()` / `rss()` for discovery. --- # Nuxt (https://opinly.ai/docs/frameworks/nuxt) In Nuxt you fetch content server-side with `useAsyncData`, render it with `@opinly/vue`, and drive SEO with `@opinly/nuxt` (which shapes a `useHead()` payload). ## Install [#install] ```bash pnpm add @opinly/backend @opinly/vue @opinly/nuxt @opinly/shared ``` ```bash npm i @opinly/backend @opinly/vue @opinly/nuxt @opinly/shared ``` ```bash yarn add @opinly/backend @opinly/vue @opinly/nuxt @opinly/shared ``` Add your API key to the environment (`OPINLY_API_KEY`). The fetch runs on the server, so the key stays private. ## Fetch, render, and add SEO [#fetch-render-and-add-seo] ```vue ``` `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 {node.attrs?.alt, }} /> ``` 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 ``` `` 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. ## Connecting from Claude Code [#connecting-from-claude-code] Add the server in one command: ```bash claude mcp add --transport http opinly https://mcp.opinly.ai/mcp ``` Claude Code opens the same OAuth flow on first use. 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. Pass your own `fetch` to control caching. On Next.js, **tag** the responses: `cache: 'force-cache'` is what puts them in the data cache, and the tags are what let a webhook invalidate exactly what changed (see [Webhooks](/docs/webhooks)). ```ts // Next.js: cache responses under one tag so a webhook can drop them all const opinly = createOpinlyClient({ fetch: (url, init) => fetch(url, { ...init, cache: 'force-cache', next: { tags: ['opinly'] } }), }) ``` Tags beat path-based invalidation here: `revalidateTag` works whether the route rendering the data is static or dynamic, while `revalidatePath` silently does nothing for dynamic routes on a self-hosted/OpenNext deployment. Bust them with `revalidateTag('opinly', { expire: 0 })` — on Next 16+ the second argument is required, and `{ expire: 0 }` is the immediate drop you want on a publish webhook (see [Webhooks](/docs/webhooks)). ## 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. | | `tags()` | `TagSummary[]` | Topic tags, each with a count of its published 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 exports the webhook event type for content changes: ```ts import type { OpinlyWebhookEvent, ContentRouteChange } from '@opinly/backend' // { type: 'content.routes-changed'; data: { changed: ContentRouteChange[] } } ``` Each `ContentRouteChange` is `{ type, slug, lastModified }` — the same shape as an entry from `routes()` — so you map them onto your own routing. Tag changes are included, which covers a change to a post's tag membership (that invalidates the post's own page too). 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/analytics) (publishable `pk-` key), so you don't send those yourself. Sending a purchase from your server? Read [Linking server events to browser visitors](/docs/analytics/server-events) first — passing `anonId` or `email` is what ties the sale back to the campaign that earned it. Without one, the revenue is recorded but shows up as "direct". ## 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, read from `window.opinly.anonId` in the browser and passed to your server. See [Linking server events](/docs/analytics/server-events). | ## 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, or they're reserved for system use, 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 ( {node.attrs?.alt ), // 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` * **Routes:** `/v1/content` for reading content, `/v1/events` for recording events * **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`, `tag`, `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/tags` | All tags with their published-post counts. | — | | `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) | | `POST` | `/v1/events` | Record a server-side event. Returns **201**. | Body: `event`, `properties`, `externalEventId`, `email`, `anonId` | | `POST` | `/v1/events/purchase` | Record a purchase. Returns **201**. | Body: `orderId`, `value`, `currency`, `email`, `anonId` | The two `/v1/events` routes are the HTTP form of [`track()`](/docs/reference/events) — see [Linking server events](/docs/analytics/server-events) for what `anonId` and `email` do. 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 , <meta>, and <script type="application/ld+json"> yourself ``` </Tab> </Tabs> ## Sitemap & RSS [#sitemap--rss] Fetch every addressable route once with `opinly.routes()`, then shape entries with `@opinly/shared`'s `buildSitemapEntries` / `toSitemapXml` (or `sitemapUrl` / `routeParams` per route) — posts flat, categories/authors prefixed per your config. `opinly.rss()` covers the feed. See the framework guides for full route examples.