HTTP API
Everything the editor does goes through one catch-all route handler, mounted at
apiPath (/api/copy-ink by default):
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.
| Failure | Status | Code |
|---|---|---|
| No session | 401 | NOT_AUTHENTICATED |
| Session, not on the allowlist | 403 | NOT_AUTHORISED |
| Anything else known | 400 | the CopyInkErrorCode |
| Unexpected | 500 | UNKNOWN |
Errors are JSON: { "error": "…", "code": "…" }.
Routes
GET /auth/signin
Redirects to the provider's authorize URL. Sets short-lived state and return cookies.
| Query | Description |
|---|---|
next | Path 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.
| Query | Default |
|---|---|
scope | '' (home) |
locale | defaultLocale |
{ "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.
| Query | Required |
|---|---|
collection | yes |
locale | no, 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.
| Query | Default |
|---|---|
locale | defaultLocale |
{ "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:
kind | Payload |
|---|---|
field | scope, locale, field, before, after |
image | scope, locale, field, before, path, filename, width, height, alt, data (base64) |
item-create | collection, locale, slug, values |
item-delete | collection, locale, slug |
item-draft | collection, locale, slug, draft |
item-reorder | collection, 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" }
| Failure | Status | Code |
|---|---|---|
| Empty changeset | 400 | EMPTY |
| The branch moved | 409 | STALE_BASE |
| The forge refused | 502 | BACKEND_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\"." }