Skip to the documentation
VersoAPI
API

One audience of one site, over HTTP.

Read the pages of a site, open a proposal against them, watch what happened, and search — with a credential that is worth exactly one audience of one site, and which discloses nothing about the audiences it is not worth.

Examples use a site whose slug is harbour — two audiences, one everybody can read and one only its members can. Responses are captured from the running software; long ones are trimmed, and a trim is always marked .

00

What this API is

Two paragraphs, then the requests.

This is the API of the engine underneath Verso — the same one the product’s own screens call, and the one संचिका Sanchika, a separate product for government record-keeping, is built against. There is no second, reduced surface for outsiders: an editor pressing Send for review and a script posting to /api/v1/pages arrive at the same place and leave the same record behind.

Everything is addressed as one audience of one site. A site is a set of pages; an audience is who may see them, and each audience answers with its own pages, its own proposals and its own history. A credential is issued for a single audience, which is what makes it safe to hand to a piece of software — and a request that names an audience the credential is not for is not refused, it simply finds nothing there.

01

Authentication

A bearer token, issued for one audience and one audience only. Naming another in the query string grants nothing.

Where a token comes from

A site administrator issues one in the product, under Settings → API tokens (/s/<site>/settings/api). The form asks what the token is for, which audience — one, chosen from that site’s audiences, and not changeable afterwards — what it may do, and when it should expire. The secret is shown once, on that screen, and is never recoverable; only its hash is stored, so a database dump yields no working credentials. Reissue rather than recover.

Send it as an ordinary bearer credential. The token string begins vs_, so it is recognisable in a log or a support ticket.

Request
export VERSO=https://getverso.dev
export TOKEN=vs_…

curl -s "$VERSO/api/v1/pages?site=harbour" \
  -H "Authorization: Bearer $TOKEN"

The part worth reading twice

A token is bound to its audience at the moment it is issued. The grant is keyed on that audience inside the resolver, so naming a different one in the query string does not widen it — and, because a credential must never confirm what it cannot reach, the answer is 404, not 403. Both calls below carry the same valid token; the second one names the members-only audience.

Request
curl -s -o /dev/null -w '%{http_code}\n' \
  "$VERSO/api/v1/pages?site=harbour" -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' \
  "$VERSO/api/v1/pages?site=harbour&surface=internal" -H "Authorization: Bearer $TOKEN"
what came back
200
404

The blast radius of a leaked token is therefore one audience of one site, and a holder of it cannot learn that another audience exists.

What a token may do

Capabilities, not a role. Roles belong to people, who acquire responsibilities over time; a token is issued for a job and should not gain one because somebody was promoted.

read
List and fetch pages, proposals and events on this audience.
search
Query this audience’s own index.
ai
Ask questions of this audience, where that is switched on.
propose
Open a proposal. Not issuable from the product today — see below.
merge
Publish a proposal. Not issuable from the product today — see below.

Writing needs a credential the token screen will not issue

The token form offers read, search and ai and nothing else. The resolver would honour propose and merge on a token perfectly well — the write routes in §03 and §04 are real and are exercised by the test suite — but a bearer string with no second factor and no lockout should not be one form submission away from changing what a customer has published. So the writing half of this API is reachable today by a signed-in person’s own session, and a writing token is a decision that will arrive with its own review rather than as a checkbox that was already there.

No token at all, and a token that is wrong

Neither is an error. A request with no credential is an anonymous visitor: it gets whatever the public audience of that site publishes, and finds every other audience absent. A request carrying a string that is not a live token is the same anonymous visitor — the credential resolves to nobody rather than to a refusal, so nothing is learned by guessing. Both are the honest answer, and both are why 404 is the interesting status code here and 401 never appears.

02

The first request

Address the site and the audience explicitly; read the pages back.

Two query parameters address everything: site is the site’s slug and surface is the audience’s key. Both are optional, and a machine caller should nonetheless always send them — omitted, the answer is decided by the request’s host and cookies, which is right for a browser and wrong for a script. Naming them grants nothing: they decide which question is asked, never who is allowed to ask it.

Request
curl -s "$VERSO/api/v1/pages?site=harbour" \
  -H "Authorization: Bearer $TOKEN"
200
{
  "pages": [
    {
      "id": "pg_home",
      "slug": "/",
      "title": "Harbour",
      "description": "How Harbour works, for the people who use it.",
      "section": null,
      "tags": [],
      "path": "index.mdx",
      "frontmatter": {
        "title": "Harbour",
        "id": "pg_home",
        "description": "How Harbour works, for the people who use it."
      }
    },
    {
      "id": "pg_refunds",
      "slug": "/guides/refunds",
      "title": "Refunds",
      "description": "When money goes back, and how long it takes.",
      "section": "/guides",
      "tags": ["payments", "policy"],
      "path": "guides/refunds.mdx",
      "frontmatter": {
        "title": "Refunds",
        "id": "pg_refunds",
        "description": "When money goes back, and how long it takes.",
        "tags": ["payments", "policy"]
      }
    }
    …
  ]
}

Three fields are worth knowing before §03. id is permanent: it survives a page being renamed or moved to another section, so it is the thing to store. slug is the address a reader visits, and it changes when the page moves. frontmatter is the page’s own settings block, returned verbatim and never rewritten on the way out.

Answers under /api/v1 carry Cache-Control: private, no-store. What one credential may read is not what the next one may, and a shared cache that forgets that is a disclosure.

03

Pages

The pages of an audience, read at the address a reader would use — and written as proposals, never in place.

One page, record and text

A page is addressed by its slug, slashes included: /api/v1/pages/guides/refunds. The answer carries the indexed record and the exact text of the page as this audience has it, so a client that fetches, edits and sends it back is doing precisely what the editor does.

Request
curl -s "$VERSO/api/v1/pages/guides/refunds?site=harbour" \
  -H "Authorization: Bearer $TOKEN"
200
{
  "page": {
    "id": "pg_refunds",
    "slug": "/guides/refunds",
    "title": "Refunds",
    "description": "When money goes back, and how long it takes.",
    "section": "/guides",
    "tags": ["payments", "policy"],
    "path": "guides/refunds.mdx",
    "frontmatter": { … }
  },
  "source": "---\ntitle: Refunds\nid: pg_refunds\ndescription: When money goes back, and how long it takes.\ntags: [payments, policy]\n---\n\n# Refunds\n\nA refund is issued to the original method of payment within five working days.\n"
}

source is the whole file, settings block and all. Send it back through PUT with the parts you changed and nothing else touched — a client that reconstructs the file from the record instead will silently drop any setting it does not know about.

Adding a page adds a proposal

POST /api/v1/pages takes a title, optionally the section it belongs in and the text to start with. What it creates is a proposal: nothing is on the live site, the answer names the proposal now carrying the new page, and publishing is a separate act in §04 with a separate capability. The API gets no shortcut past review, because a shortcut here would be a second, quieter door onto a customer’s published documentation.

Request
curl -s -X POST "$VERSO/api/v1/pages?site=harbour" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Returns","section":"guides","content":"# Returns\n\nSend it back within thirty days.\n"}'
201
{
  "proposalId": "prop_EzrcwQcJeLhgqMth",
  "pageId": "pg_KVqzTVeF2qUrz5Gz",
  "path": "guides/returns.mdx",
  "slug": "/guides/returns"
}

The address is derived from the title rather than asked for, and a handful of root addresses are refused because the product already occupies them — those come back 400 naming the address, and a page that already lives at the derived slug comes back 409.

PUT /api/v1/pages/{slug}
Replace the text. Body is { "source": "…" }; the answer names the proposal carrying the edit.
DELETE /api/v1/pages/{slug}
Remove the page — also a proposal, so a removal is reviewed like any other change.
04

Proposals

The queue of changes waiting on one audience, what each one would do, and the act that makes it live.

The list is filtered to the audience the credential resolves to, not to the site: a credential for the public audience must not see another audience’s drafts in flight, for exactly the reason it cannot read their pages. Absent, not refused. Filter with state=open|merged|conflicted|closed.

Request
curl -s "$VERSO/api/v1/proposals?site=harbour" \
  -H "Authorization: Bearer $TOKEN"
200
{
  "proposals": [
    {
      "id": "prop_ensgj6dsneMepU9x",
      "number": 2,
      "title": "Correct the refund window",
      "body": "Finance says five working days, not seven.",
      "state": "open",
      "origin": "api",
      "author": "API token “docs”",
      "baseRef": "main",
      "baseSha": "3f7f587ddb3dffcc055f44747b88c4ce415f4a9e",
      "headSha": "647cf72501ba594d5e68e794ed6b948a0b8dfa96",
      "mergeCommitSha": null,
      "createdAt": "2026-08-10T18:53:38.492Z",
      "updatedAt": "2026-08-10T18:53:38.550Z",
      "mergedAt": null
    }
    …
  ]
}

author says which credential wrote it — a person’s name from a session, the token’s own name from a token. That is what an operator revoking one of nine tokens needs to see in the history. origin says which door it came through: app, api, git_push, import or ai.

Opening one directly

POST /api/v1/proposals takes a title and a files map — each key a path inside the site, each value the whole file, or null to remove that one. It is the general form of everything in §03.

Request
curl -s -X POST "$VERSO/api/v1/proposals?site=harbour" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Correct the refund window",
    "body": "Finance says five working days, not seven.",
    "files": { "guides/refunds.mdx": "---\ntitle: Refunds\nid: pg_refunds\n---\n\n# Refunds\n\nFive working days.\n" }
  }'
201
{ "proposalId": "prop_ensgj6dsneMepU9x" }

What it would actually do

Fetching one proposal returns it together with its ledger: every change it contains, grouped and ranked by consequence, each with the reason in plain language. This is the same reading a reviewer gets on screen, and it is the part of this API that has no equivalent elsewhere — a machine can ask what a change would do before anybody approves it.

Request
curl -s "$VERSO/api/v1/proposals/prop_EzrcwQcJeLhgqMth" \
  -H "Authorization: Bearer $TOKEN"
200
{
  "proposal": { "id": "prop_EzrcwQcJeLhgqMth", "number": 1, "title": "Add /guides/returns", "state": "open", … },
  "ledger": {
    "total": 5,
    "items": [
      {
        "id": "set:guides/returns.mdx:title",
        "group": "page_settings",
        "consequences": [],
        "reason": "Changed the page title: unset → Returns",
        "page": "/guides/returns",
        "path": "guides/returns.mdx"
      },
      {
        "id": "blk:guides/returns.mdx:1",
        "group": "added_removed",
        "consequences": ["added"],
        "reason": "New text was added: \"Send it back within thirty days.\"",
        "page": "/guides/returns",
        "path": "guides/returns.mdx"
      }
      …
    ]
  }
}

A proposal is fetched by id alone. The audience it belongs to is the one it names itself — the caller’s site and surface parameters get no vote — so a proposal on an audience you cannot read is a plain 404, indistinguishable from an id that never existed.

Publishing

Publishing needs merge and an accountable person: the session’s user, or the person who issued the token. A credential that names nobody accountable is refused with 403 and told why. The act records an approval covering the whole ledger in the publisher’s name and then publishes — the same record every publish made on screen leaves, so the history cannot tell which door it came through, and nobody should be able to.

Request
curl -s -X POST "$VERSO/api/v1/proposals/prop_EzrcwQcJeLhgqMth/publish" \
  -H "Authorization: Bearer $TOKEN"
200
{ "ok": true, "sha": "fb4d140d1d6f763afbe8f403315a52088a450d8c" }

A proposal already published, or one that no longer applies cleanly to the live pages, answers 409; the conflicted case carries the list of pages that disagree.

05

Events

What happened on this audience, newest first, paged backwards by id.

The feed is filtered to the audience by equality, never by “not somebody else’s”. Acts that belong to no audience — a member added, a site-wide setting changed — are absent from it by construction, so a credential learns exactly what its own audience could have watched happen and nothing more.

Request
curl -s "$VERSO/api/v1/events?site=harbour&limit=3" \
  -H "Authorization: Bearer $TOKEN"
200
{
  "events": [
    {
      "id": "ev_01KZPGBXHP46PGXKPT44JQX0NC",
      "type": "proposal.merged",
      "subject": { "type": "proposal", "id": "prop_EzrcwQcJeLhgqMth" },
      "payload": {
        "number": 1,
        "commit_sha": "fb4d140d1d6f763afbe8f403315a52088a450d8c",
        "pages_touched": ["guides/returns.mdx"]
      },
      "occurredAt": "2026-08-10T18:53:38.742Z"
    },
    {
      "id": "ev_01KZPGBXE104F5PVGBZ7R7VJ69",
      "type": "proposal.approved",
      "subject": { "type": "proposal", "id": "prop_EzrcwQcJeLhgqMth" },
      "payload": {
        "number": 1,
        "approver": "Ida Okonjo",
        "changes_total": 5,
        "changes_opened": 5,
        "approved_unopened": 0
      },
      "occurredAt": "2026-08-10T18:53:38.625Z"
    }
    …
  ],
  "nextBefore": "ev_01KZPGBX9WPGZ2ETCZ78S9PERG"
}
limit
1–200, default 50.
before
An event id. Answers events strictly older than it — pass nextBefore straight back to walk the history. It is null on the last page.
type
One event type, e.g. page.published.

Ids are ULIDs, so sorting them lexicographically is sorting them by time. That is why the cursor is an id rather than a timestamp: two things that happened in the same millisecond still have an order, and paging cannot skip or repeat one.

06

Search

Retrieval over one audience's own index — keyword and meaning, fused.

Search needs the search capability. It runs over this audience’s own physical partition of the index rather than over a shared table with a filter on it: a credential cannot reach another audience’s text because those rows are not in the tables its queries name. mode=keyword skips the meaning half, which is the right choice for very short queries; limit is 1–50 and defaults to 10.

Request
curl -s "$VERSO/api/v1/search?site=harbour&q=refund" \
  -H "Authorization: Bearer $TOKEN"
200
{
  "hits": [
    {
      "chunkId": "chk_40178cce0bbd05710210",
      "pageId": "pg_refunds",
      "slug": "/guides/refunds",
      "title": "Refunds",
      "breadcrumb": "Harbour › Refunds",
      "anchorSlug": null,
      "url": "/guides/refunds",
      "snippet": "# Refunds A refund is issued to the original method of payment within five working days.",
      "blockKeys": ["blk_zwqbUYUnHEGyr92p", "blk_fLvut4uSHX98UqPU"],
      "score": 0.03278688524590164,
      "rank": 1,
      "sources": [
        { "source": "fts", "rank": 1 },
        { "source": "vec", "rank": 1 }
      ]
    }
  ],
  "mode": "hybrid",
  "notes": [],
  "tookMs": 2,
  "indexed": true
}
mode
What actually ran, which is not always what was asked for. Ask for hybrid on a corpus with no vectors and it answers keyword.
notes
Why, when it is not what was asked for. Empty when it is.
indexed
false when this audience has never been indexed — an empty result, not an error, and the note says so.
blockKeys
The exact blocks the snippet came from. A citation that survives the page being edited around it.
anchorSlug
The heading to link to inside the page, or null above the first one.

Search is rate-limited. Over the limit answers 429 with Retry-After in seconds; honour it rather than retrying immediately.

07

Refusals, and what they disclose

One error body everywhere, and one rule about which status code a refusal is allowed to be.

Every refusal has the same two fields: a sentence a person can act on, and a code a program can switch on. There is no third shape and no route under /api/v1 that invents its own.

Request
curl -s "$VERSO/api/v1/pages/guides/nope?site=harbour" \
  -H "Authorization: Bearer $TOKEN"
404
{ "error": "Not found", "code": "not_found" }

404, not 403 — and the difference between them

The rule is worth stating precisely, because it is not “everything is a 404”. Not being able to see something is 404. Being able to see it and not being allowed to change it is 403. A credential that can already read an audience learns nothing new from being told it may not write there, so the honest refusal is the specific one; a credential that cannot read the audience must not be able to distinguish it from an audience that does not exist, so the honest refusal is silence in the shape of a 404.

Request
# a read-only credential, naming a write it can see
curl -s -X POST "$VERSO/api/v1/pages?site=harbour" \
  -H "Authorization: Bearer $READ_ONLY" \
  -H 'Content-Type: application/json' -d '{"title":"Refused"}'
403
{ "error": "Forbidden", "code": "forbidden" }

The same rule covers a site that does not exist, an audience that is closed or past its expiry, and a proposal on an audience you cannot read: all four are 404, all four with the same body, and none of them tells you which of the four it was.

The codes

not_found · 404
No such site, audience, page or proposal — or nothing you may read. Deliberately the same answer.
forbidden · 403
You can see it and may not do this to it. Also the refusal when a publish names nobody accountable, and then the sentence says so.
bad_request · 400
The request is malformed or the product reserves the address, and the sentence names the problem: Send JSON with a non-empty "title".
conflict · 409
A page already lives at that address; a proposal is already published, or no longer applies cleanly to the live pages.
— · 429
Too many searches. Carries Retry-After in seconds.

There is no 401. A missing or unrecognised credential is not an error here — it is an anonymous caller, who gets the public audience and finds every other one absent. See §01.

08

The reference

Every operation, generated from the specification this deployment is actually serving.

Below is the whole of /api/v1, rendered on the server from the same specification document that /api/openapi.json serves to machines — one source, so a route added without a line in it fails a test rather than quietly appearing here. The reference describes; it does not fire requests. There is no try-it console, and a reference that quietly shipped one would be shipping a security decision nobody made.

Verso API · 1.0.0 · every operation, expanded. Machine-readable at /api/openapi.json.
GET/api/v1/pagesList the pages of a surface

Requires read. Answers every page on the surface the credential resolves to.

Parameters
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Responses
  • 200 The pages, in navigation order.
    • pages Page[]
  • 404 No such surface, or no read on it.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
POST/api/v1/pagesCreate a page (as a proposal)

Requires propose. Opens a proposal containing the new page and answers its id — nothing is live until the proposal publishes.

Parameters
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Request body
  • title string · required
  • section stringSection slug, e.g. guides. Top level when absent.
  • content stringMDX body. Defaults to the title as a heading.
Responses
  • 201 The proposal now carrying the page.
    • proposalId string
    • pageId string
    • path string
    • slug string
  • 400 Missing title, or an address the product reserves.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
  • 409 A page already lives at that address.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/pages/{slug}Read one page, record and source

Requires read. slug is the reader’s address, e.g. guides/refunds. Answers the indexed record and the exact MDX at the surface’s ref.

Parameters
  • slug path · required · stringPath-style slug; slashes allowed.
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Responses
  • 200 The page.
    • page Page
    • source stringThe complete MDX file, frontmatter included.
  • 404 No page there, or nothing you may read.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
PUT/api/v1/pages/{slug}Replace a page’s source (as a proposal)

Requires propose. Send back the edited source a GET returned.

Parameters
  • slug path · required · string
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Request body
  • source string · requiredThe complete MDX file to store.
Responses
  • 200 The proposal carrying the edit.
    • proposalId string · required
    • path string
  • 404 No page there.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
DELETE/api/v1/pages/{slug}Remove a page (as a proposal)

Requires propose. The removal is a proposal like any other change.

Parameters
  • slug path · required · string
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Responses
  • 200 The proposal carrying the removal.
    • proposalId string · required
    • path string
  • 404 No page there.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/proposalsList proposals on a surface

Requires read. Only this surface’s proposals — another audience’s drafts are absent, not refused. Filter with state=open|merged|conflicted|closed.

Parameters
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
  • state query · string
Responses
  • 200 The proposals, most recently updated first.
    • proposals Proposal[]
POST/api/v1/proposalsOpen a proposal

Requires propose. files maps repo-relative paths to full content, or null to remove a path.

Parameters
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Request body
  • title string · required
  • body string
  • files object · requiredRepo-relative path → full file content, or null to remove that path.
Responses
  • 201 Opened.
    • proposalId string · required
    • path string
  • 400 No title, no files, or a path that is not repo-relative.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/proposals/{id}Read one proposal and its ledger

Requires read on the surface the proposal itself names — the caller’s site and surface parameters get no vote.

Parameters
  • id path · required · string
Responses
  • 200 The proposal and a consequence-ranked summary of its changes.
    • proposal Proposal
    • ledger LedgerSummary
  • 404 No such proposal you may see.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
POST/api/v1/proposals/{id}/publishPublish a proposal

Requires merge, and an accountable person: the session’s user, or the user who minted the token. Records an approval covering the whole ledger in the publisher’s name, then squash-merges — the same record every other publish leaves.

Parameters
  • id path · required · string
Responses
  • 200 Live.
    • ok boolean
    • sha string
  • 403 No merge, or no accountable person behind the credential.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
  • 409 Already published, or it conflicts with Live.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/searchSearch a surface

Requires search. Hybrid keyword + vector over this surface’s own physical index partition; mode=keyword skips vectors.

Parameters
  • q query · required · string
  • limit query · integer1–50, default 10.
  • mode query · string'hybrid' (default) or 'keyword'.
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
Responses
  • 200 Ranked results with block-level citations.
  • 429 Rate limited; honour Retry-After.
    • error string · requiredA sentence a person can act on.
    • code string · required'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/eventsList a surface’s events

Requires read. Newest first; events with no surface (site-wide acts) are absent from this feed by construction. Page backwards with before=<id>.

Parameters
  • site query · stringSite slug. Explicit addressing for machine callers; omitted, the host decides.
  • surface query · stringSurface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
  • limit query · integer1–200, default 50.
  • before query · stringAn event id; answers events strictly older.
  • type query · stringe.g. 'page.published'.
Responses
  • 200 The events.
    • events Event[]
    • nextBefore stringCursor for the next page, or null.