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/contentfor reading content,/v1/eventsfor 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 absolutehttp(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
| 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() — 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…"
}codeis a stable, machine-readable identifier (UNAUTHORIZED,VALIDATION_ERROR,INVALID_CURSOR,NOT_FOUND,INTERNAL_ERROR).request_idis echoed in theX-Request-Idresponse header on every request — quote it when contacting support. Send your ownX-Request-Idto correlate requests end to end.