Skip to main content

HTTP API

Everything the editor does goes through one catch-all route handler, mounted at apiPath (/api/copy-ink by default):

app/api/copy-ink/[...copyInk]/route.ts
import { createCopyInkHandler } from 'copy-ink/server'
import copyInk from '@/copy-ink.setup'

export const { GET, POST } = createCopyInkHandler(copyInk)
export const runtime = 'nodejs'

You do not normally call these yourself — the editor does. They are documented because a custom sign-in page, a health check or an integration test may need them.

Authentication

Every route except the auth ones requires a session cookie (copy-ink-session, httpOnly) and re-checks the user against auth.allowlist. The forge access token stays server-side and never appears in a response.

FailureStatusCode
No session401NOT_AUTHENTICATED
Session, not on the allowlist403NOT_AUTHORISED
Anything else known400the CopyInkErrorCode
Unexpected500UNKNOWN

Errors are JSON: { "error": "…", "code": "…" }.

Routes

GET /auth/signin

Redirects to the provider's authorize URL. Sets short-lived state and return cookies.

QueryDescription
nextPath to return to. Normalised to a same-origin path; anything else becomes /

GET /auth/callback

The OAuth callback. Verifies state against the cookie, exchanges the code for a token, checks the allowlist, creates a session, enables Next draft mode, and redirects to the stored return path.

POST /auth/signout

Destroys the session, disables draft mode, clears the cookie. Returns { ok: true }.

GET /session

{ "session": { "user": { "login": "jane", "name": "Jane", "email": "…", "avatarUrl": "…" }, "expiresAt": 1767225600000 } }

{ "session": null } when signed out. This is the only route that does not require a session.

GET /content

One scope's parsed document.

QueryDefault
scope'' (home)
localedefaultLocale
{ "scope": "about", "locale": "en", "exists": true, "data": { "heroTitle": "Hello!" } }

No locale fallback here — the editor needs to know what this locale's file actually contains, not what a visitor would see.

GET /collections

One collection's schema and items, drafts included.

QueryRequired
collectionyes
localeno, defaults to defaultLocale
{
"locale": "en",
"collection": { "name": "posts", "label": "Blog posts", "slugFrom": "title", "fields": { } },
"items": [{ "slug": "hello-world", "draft": false, "order": null, "values": { } }]
}

GET /sitemap

Every scope that has content, with its repo-relative file path. Powers the editor's site map panel.

QueryDefault
localedefaultLocale
{ "locale": "en", "scopes": [{ "scope": "about", "path": "content/en/about/_index.yml" }] }

POST /publish

Turns a changeset into one commit.

{
"changes": [
{
"kind": "field",
"scope": "about",
"locale": "en",
"field": "heroTitle",
"before": "Hello!",
"after": "Hi!"
}
],
"baseSha": "a1b2c3d",
"message": "content: update about"
}

Each change carries a kind:

kindPayload
fieldscope, locale, field, before, after
imagescope, locale, field, before, path, filename, width, height, alt, data (base64)
item-createcollection, locale, slug, values
item-deletecollection, locale, slug
item-draftcollection, locale, slug, draft
item-reordercollection, locale, order (slugs, in order)

An image's bytes travel in the same request as the text, so an upload and the copy referring to it land in one commit.

message is optional; without one, it is built with attribution to the signed-in user. baseSha may be null, which skips the concurrency check.

{ "ok": true, "commitSha": "e4f5a6b", "url": "https://github.com/you/site/commit/e4f5a6b" }
FailureStatusCode
Empty changeset400EMPTY
The branch moved409STALE_BASE
The forge refused502BACKEND_ERROR

A 409 is the concurrency guard: the editor tells the client someone else changed the page and asks them to reload. There is no merging in v1.

Unknown routes

{ "error": "Unknown copy-ink route \"/whatever\"." }