OpinlySDK

REST API (/v1)

The versioned HTTP contract behind @opinly/backend — endpoints, auth, pagination, and error shapes.

@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
  • Interactive docs: /v1/docs (Scalar)

Authentication

Send your API key as a bearer token in the Authorization header on every request:

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):

{
  "type": "https://opinly.ai/docs/reference/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "code": "UNAUTHORIZED",
  "request_id": "9f1c…"
}

Site headers (optional)

@opinly/backend also sends where your blog lives on every request. If you call the API directly, send them yourself:

curl https://sdk.opinly.ai/v1/content/posts \
  -H "Authorization: Bearer sk-…" \
  -H "x-opinly-site-url: https://example.com" \
  -H "x-opinly-blog-prefix: /blog"
  • x-opinly-site-url — an absolute http(s) URL, e.g. https://example.com. Anything else is ignored.
  • x-opinly-blog-prefix — the path your posts live under, e.g. /blog. Leave it out if posts live at the root.

Opinly composes each post's live URL from these (<site-url><blog-prefix>/<slug>). Without them, or the Site URL and Blog path entered when you finish setup, Opinly can't check that Backlink Exchange links in your posts are live.

You can send them on any request, and sending them on every request is fine. The first values received are kept: later requests never overwrite them, so a preview deploy can't repoint your site. Make sure the first request that carries them comes from production. To change them later, edit the Integration card in Settings → Account → Content Settings.

If your platform is set to Next.js on the Content page, the first request carrying these headers also completes the connection, the same as finishing the setup checklist.

Endpoints

MethodPathDescriptionParams
GET/v1/content/postsPublished posts (cursor-paginated, filterable).limit (1–100, default 12), cursor, category, author, tag, sort (newest|oldest)
GET/v1/content/postA single post by slug (404 if none).slug (the post's slug, e.g. my-post)
GET/v1/content/categoriesCategories, each with up to 5 latest posts.—
GET/v1/content/tagsAll tags with their published-post counts.—
GET/v1/content/authorsAll authors with sample posts.—
GET/v1/content/authors/{slug}A single author page.slug (path)
GET/v1/content/routesAll addressable routes (sitemap + static generation): typed { type, slug, lastModified }, bare slugs.—
GET/v1/content/rssRSS feed items.limit (1–100, default 20)
POST/v1/eventsRecord a server-side event. Returns 201.Body: event, properties, externalEventId, email, anonId
POST/v1/events/purchaseRecord a purchase. Returns 201.Body: orderId, value, currency, email, anonId

The two /v1/events routes are the HTTP form of track() — see Linking 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=<slug>, and a single post is GET /v1/content/post?slug=<post> (returns the post, or 404 if there's none).

Pagination

GET /v1/content/posts is cursor-paginated. The response is an envelope:

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:

# 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 Link: <…>; rel="next" header. Cursors are opaque — don't parse or construct them.

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:

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 for the typed methods.

Errors

Every non-2xx response is RFC 9457 application/problem+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.