Otava for developers

Your sites,
from your own code.

Everything the portal does — read a site, edit its draft, start a code change, publish, watch a deploy, attach a domain — is an HTTP call with an API key in the header. There is no browser state to reproduce and no session to keep alive: a key names its own account, so the same call means the same thing from a laptop, a CI job or a cron.

Quick start

  1. In the portal, open Settings → Connections and create an API key. Tick only the scopes the integration needs, and only the sites it should reach.
  2. Copy the token. It starts otv_sk_ and it is shown once — Otava stores only its SHA-256, so a lost key is replaced rather than recovered.
  3. Send it as a bearer token.
curl https://otava.ai/api/v1/sites \
  -H "Authorization: Bearer otv_sk_..."

Lists answer in one shape, always — the page, and where the next one starts. Everything else answers with the resource itself, unwrapped.

{
  "data": [ /* one page of sites */ ],
  "next_cursor": "eyJrIjoic2l0ZXMiLCJvIjoyNX0"
}

The base URL is this host. Every path below hangs off /api/v1, and the OpenAPI document names the server it was served from, so a generated client points at the right place without being told.

Authentication

One header, on every request: Authorization: Bearer otv_sk_…. There is no other way in, and there is deliberately no cookie path — nothing under /api/v1 reads a session, so a browser’s ambient authority cannot arrive here at all.

What a key may do

A key carries scopes, and reaches either every site in the account or the specific ones it was granted. Both are intersected, on every request, with what the person who created the key can still do today: demote them and their key loses exactly what they lost; remove them from the account and it authenticates as nothing. A key is never a way to do more than a person can do by hand.

ScopeCovers
site:readRead a site, its content, versions, deploys, settings and domains.
site:writeEdit a draft and start code changes. Nothing becomes public.
site:publishPublish, restore a version, redeploy, attach a domain.
account:sitesDecide which sites exist: create one, delete one.
account:readPlan, credit balance, usage, webhook endpoints, API keys.
account:writeChange account-level things: webhooks, revoking a key.

Lifetime and revocation

Keys expire in 30, 90 or 365 days, or never — a pipeline that dies at 3am because a credential aged out is its own kind of outage, so “never” is a real choice. Many keys can be live at once and each is revoked on its own, which is the whole point: rotating one integration should not cut off the others.

A key can revoke keys, including itself, and can never mint one. Minting is a browser action because the consent it collects — these scopes, these sites, this lifetime — only means something if a person gave it, and because a credential that can mint a quiet sibling survives its own revocation.

Three credentials, three doors. otv_sk_ keys open /api/v1 and nothing else. otv_live_ site tokens and OAuth access tokens open the MCP endpoints and nothing else. A credential presented at the wrong door is refused by its shape, before any lookup — so if a call is failing with invalid_key, check which door you are knocking on.

Errors

Every refusal under /api/v1 is this envelope and nothing else. Write catch once.

{
  "error": {
    "code": "site_not_found",
    "message": "No site with that id.",
    "details": {}
  }
}

code is the thing to branch on. It comes from one registry, it always carries the same HTTP status, and details is always an object — empty when there is nothing to add, so a client never has to tell a missing field from a null one. A schema failure fills it with the field paths:

{
  "error": {
    "code": "validation_failed",
    "message": "The request didn't match what this endpoint expects.",
    "details": {
      "issues": [
        { "field": "instruction", "code": "too_big", "message": "Too long" }
      ]
    }
  }
}

Every code

Read from this server, so it is the list this deploy can actually return rather than the list somebody wrote down once.

Reading the error codes from this server…

Pagination

Every list takes ?limit= and ?cursor=, and answers { data, next_cursor }. The default page is 25 and the ceiling is 100; asking for more is refused rather than quietly clamped, because the only way to discover a clamp is to count rows.

curl "https://otava.ai/api/v1/account/usage?limit=50" \
  -H "Authorization: Bearer otv_sk_..."

# then, for the next page:
curl "https://otava.ai/api/v1/account/usage?limit=50&cursor=<next_cursor>" \
  -H "Authorization: Bearer otv_sk_..."

next_cursor is always present and null on the last page. Treat it as opaque — pass back exactly what you were given. A cursor from a different list is refused with invalid_cursor rather than read as an offset into this one, which is what stops a mixed-up cursor from silently paging through the wrong data.

Idempotency

Anything that creates or spends honours an Idempotency-Key header: a string you choose, unique per request. Send the same request twice under one key and the work happens once — the second call replays the first response, with Idempotency-Replayed: true so you can tell a replay from a fresh run.

curl -X POST https://otava.ai/api/v1/changes \
  -H "Authorization: Bearer otv_sk_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f0d2b9e-1f3c-4f8a-9a7d-1c2f4b6d8e0a" \
  -d '{"site_id":"<site id>","instruction":"Raise class prices to $45"}'

# 202 Accepted, Location: /api/v1/changes/<id>
# poll it:
curl https://otava.ai/api/v1/changes/<id> \
  -H "Authorization: Bearer otv_sk_..."
  • A different request under a key already used is refused with idempotency_conflict. Replaying an answer you did not ask for would hide a real bug in the caller.
  • While the first request is still running, a second gets idempotency_in_progress. Wait a moment and retry.
  • A request that failed releases its key: fix the body and send it again under the same key your retry logic already chose.
  • A key is remembered against the API key that used it, not against your account — so two credentials can pick the same string without colliding, and a retry sent under a different API key runs as a fresh request rather than replaying.
  • Keys are remembered for 24 hours.

Long work answers immediately. Starting a code change or a deploy returns 202 with an id and a Location, and you poll it. Nothing on this API holds a connection open waiting for a build — if the connection dies you have lost a poll, not the job.

Rate limits

120 requests a minute per key, counted per key rather than per account so one runaway integration cannot lock its owner out of the others. A handful of endpoints — the ones that start a machine or spend credits — carry a much smaller budget of their own, counted as well as that one.

The numbers are on every response, not only on refusals, because a well-behaved client paces itself before it is told to:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1754654460
Retry-After: 37          # only on a 429

A refusal is 429 with code rate_limited. When two budgets apply, the headers describe whichever one you are closer to spending.

Endpoints

Read from this server’s OpenAPI document, which is generated from the same schemas the handlers validate with — so this table is the surface as deployed, not a description of it. Point a client generator at that URL and you get parameters, request bodies and response shapes as well.

Reading the endpoint list from this server…

Webhooks

The work worth waiting on takes minutes, so Otava can tell you when it lands instead of you asking. Register an endpoint with POST /api/v1/webhooks, pick the events, and store the signing secret it returns — it is shown once.

Events: deploy.succeeded, deploy.failed, change.completed, change.failed, site.published. A delivery looks like this:

POST /your/endpoint
X-Otava-Event: deploy.succeeded
X-Otava-Delivery: 0f3c1a...            # stable across retries — dedupe on it
X-Otava-Signature: t=1754654400,v1=9c1f...

{
  "id": "0f3c1a...",
  "event": "deploy.succeeded",
  "account_id": "...",
  "created_at": "2026-08-08T09:20:00.000Z",
  "data": { }
}

Verifying a delivery

The signature covers a timestamp and the body, which is what makes a captured delivery useless later: check the age first, then the HMAC, and only then read the body for anything else.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the bytes as they arrived. A JSON parse and re-serialise
// reorders keys, and the signature is over bytes.
export function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => part.split("=")),
  );
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return expected.length === given.length && timingSafeEqual(expected, given);
}
  • Sign the raw body, before any parse. The signature is over bytes.
  • Every retry carries a fresh t and a fresh signature over the same unchanged body, so a struggling receiver’s later attempts still verify.
  • X-Otava-Delivery is stable across retries. Deduplicate on it — at least once, not exactly once.
  • Answer with any 2xx. Anything else, including a timeout, is retried six times over about seven hours; after that the endpoint is marked broken in the portal rather than switched off, because events stopping without anyone being told is the failure this whole feature exists to prevent.
  • Endpoints must be https and must point at a public hostname.

Embedding the editor

Everything above is your code calling Otava. This is the other direction: Otava’s editor running inside your product, so your customer changes their website without leaving your page. Three parties, one flow, and one rule underneath it — an embed can never do more than the API key that minted it, and that key can never do more than the person who created it can still do by hand.

your server  ──POST /api/v1/embed/tokens (otv_sk_ key)──▶  Otava
     │                                                       │
     └──token──▶ your page ──iframe──▶ /embed/editor/:siteId─┘

Never ship your API key to the browser. This is the one sentence on the page that is entirely yours to get right, because nothing at the mint endpoint can tell a call from your server apart from a call your page made with the key in it. A key in a browser is your whole account in a browser: every site on it, every publish, and the power to revoke the other keys. What goes to the page is the token below — one site, one capability list, one set of origins, a few minutes, and a single use.

1. Mint a token on your server

curl -X POST https://otava.ai/api/v1/embed/tokens \
  -H "Authorization: Bearer otv_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "<site id>",
    "capabilities": ["editor:view", "editor:chat", "editor:publish"],
    "origins": ["https://app.yourproduct.com"],
    "actor_label": "Dana at Acme"
  }'

The endpoint needs site:read, which is genuinely the floor rather than the requirement: every embed carries editor:view, and everything above it is intersected with the scopes the key actually holds. A read-only key therefore mints a read-only editor rather than being refused. A key that holds nothing at all for that site is refused with forbidden, and a sentence naming the scopes it was granted and the role its creator has today.

201 Created

{
  "token": "otv_embed_...",
  "id": "9f2c1e...",
  "site_id": "<site id>",
  "capabilities": ["editor:view", "editor:chat"],
  "origins": ["https://app.yourproduct.com"],
  "actor_label": "Dana at Acme",
  "expires_at": "2026-08-08 09:25:00"
}
  • capabilities in the response is what was stored, not what you asked for. Assert on it. A key without publishing rights mints an embed without a Publish button, and this response is where you can see that — rather than a support ticket from a customer who cannot find one.
  • token is returned here and nowhere else; Otava stores only its SHA-256. id is that hash, which names the embed in your logs and grants nothing on its own.
  • Mint one per page view. There is nothing to cache: a cached token is one the previous page load already spent.
  • Do not send an Idempotency-Key from a shared HTTP helper. The v1 wrapper honours it on every POST, so a replay hands back the same token — and since that token is single-use, the second page to receive it fails to load. Use a distinct key per page view, or no header at all, which is what this endpoint expects.
  • Five of the refusals share the code validation_failed; details.reason tells them apart — no_capabilities, no_origins, too_many_origins, invalid_origin, ttl_out_of_range. Branch on that when you want to know whether it was your origin list or your TTL.
  • 60 requests a minute per key here, counted on top of the surface-wide 120. This is the one endpoint that writes a row per call.

2. Mount it in your page

<div id="slot" style="height: 720px"></div>

<script type="module">
  // One token per page view, minted by YOUR server. The key stays there.
  const res = await fetch("/embed-token", { method: "POST" });
  const { token, site_id: siteId } = await res.json();

  const iframe = document.createElement("iframe");
  iframe.title = "Website editor";
  iframe.src = `https://otava.ai/embed/editor/${siteId}?t=${token}`;
  iframe.setAttribute(
    "sandbox",
    "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox",
  );
  iframe.style.cssText = "width:100%;height:100%;border:0";
  document.getElementById("slot").append(iframe);

  window.addEventListener("message", (event) => {
    if (event.origin !== "https://otava.ai") return;
    if (event.source !== iframe.contentWindow) return;
    const message = event.data;
    if (!message || message.source !== "otava-embed") return;

    // ready            { siteId, capabilities, expiresAt }
    // published        { version }
    // session-expired  mint a fresh token and point the frame at it
    // error            { reason, detail } — a fresh token will not help
    if (message.type === "session-expired") location.reload();
  });
</script>

The token travels in the URL because the framing decision is made on the document response, before any script runs — a token in the fragment never reaches the server, so a frame carrying one could not be told who is allowed to frame it. It is safe there because it is single-use: by the time that URL has been written to a history entry, a Referer or an access log, it has been spent. For the same reason, leave it in the URL. The frame re-reads it on every later load to rebuild its own framing policy, and a page that tidies it away with replaceState goes blank the next time the document loads.

The frame speaks and does not listen. It posts ready, published, session-expired and error to each origin the token named — never to * — and accepts no commands, because an inbound channel is a second door into somebody’s editor, reachable by anything that can get a handle on the frame, and everything it would offer you can already do by re-mounting with a fresh token. Check event.origin and event.source before you believe a message: the first stops another frame on your page impersonating the editor, the second stops a frame that has been navigated elsewhere from still being trusted.

What the capabilities mean

A capability is a control your customer gets, not an endpoint your code may call — the two lists overlap today and are not expected to stay aligned, so the embed says what it means in its own words.

CapabilityLets your customer
editor:viewLoad the editor: what the site is, what is published, what is sitting unpublished, and the transcript of what the assistant has done. Added to every token whether you ask for it or not — an editor that cannot show the site is not an editor.
editor:chatDescribe a change and let the assistant make it. The one capability that spends your account's credits, so it is your cost as well as your customer's permission.
editor:contentEdit copy, images and layout directly, without the assistant.
editor:mediaUpload images and files into the site.
editor:undoTake back the last change.
editor:discardThrow the whole unpublished draft away. Separate from undo because it is not a bigger undo: undo walks back a step, this discards work nobody can recover.
editor:publishPut the draft live, and roll back to an earlier published version. Always its own capability, because “let my customer edit their site” and “let my customer make it public” are different sentences.
editor:settingsChange how the site builds and runs — which app in a monorepo, the run command. Infrastructure rather than content, and owner-and-admin in the portal.

The list is clamped twice. Once at mint, against the key’s scopes and its creator’s role, which is what makes the response above honest. Then again on every single request the embedded editor makes: the whole chain is re-walked — the token’s row, the key that minted it, that key’s creator, their live membership — so a customer who was publishing a minute ago stops being able to the moment the person behind your integration is demoted, mid-session, with nobody logging out. A control the embed does not hold is not rendered at all rather than disabled, because a greyed-out button is a description of what your integration is not allowed to do, published to your customer.

Why the origins have to be registered

The origins on the token become the frame-ancestors directive on the editor document, which is the whole of what stops an arbitrary page framing one of your customers’ editors. Every response Otava serves refuses framing by default; naming origins is the only way to say otherwise, and there is no boolean anywhere that means “framing on” — you cannot opt in without deciding who.

  • Exact origins only: scheme, host, and port if it is not the default. No wildcards — not *, not *.yourproduct.com — because a pattern language is a place for a mistake to hide in the one list that is doing all the work. No path, no query, no credentials.
  • https everywhere except loopback. http://localhost:3000 is allowed so you can build the integration; nothing else on plain http is.
  • Up to ten per token — enough for production, staging and a couple of preview domains.
  • A browser sends the origin your customer typed: http://localhost:4000 and http://127.0.0.1:4000 are different origins, and only one of them matching is the difference between an editor and a blank rectangle.

Get this wrong and the failure is quiet in a specific way: the browser refuses the document before any script runs, so the frame never loads, never reports anything, and your page shows an empty panel. If an embed is blank and your console has nothing in it, this is the first thing to check.

The same list is what lets the editor show your customer their own published site in its preview pane. frame-ancestors is checked against the whole chain of ancestors rather than the immediate parent, so the site inside the editor inside your page has to admit your origin too — and a published site, like everything else Otava serves, refuses framing by default. Otava mints a signed, single-site grant for that pane from the session behind the embed; it lasts a couple of minutes, names the origins on this token, and grants nothing beyond being framed. Nothing on your side is needed for it, and nothing on your side can produce one.

When the token expires

A token stays redeemable for five minutes by default — ttl_seconds takes anything from 60 to 1800 — and it is exchanged for a session within milliseconds of the frame loading. Raising the TTL buys nothing except a longer window in which a copy still works: it does not lengthen the session, which runs for eight hours from the token’s expiry and is the thing your customer is actually working inside.

There are three endings and your page is told about two of them. Once the editor is up it reports session-expired, which means ask your server for a fresh token and remount, or error, which means somebody has to fix something and a new token will walk into the same wall: the API key was revoked, its creator lost access to the site, or the site was deleted.

The third is the one to plan for. A failure before the exchange succeeds — a token that was already spent, one that expired, a framing refusal — cannot be reported to your page at all, because until the exchange returns the frame does not yet know which origins it is allowed to post to. It says so on its own face, inside the rectangle, and your page hears nothing. So give the mount a deadline of your own: if ready has not arrived within twenty seconds or so, treat it as a stale token, mint another and remount. That is the whole of what the SDK’s watchdog below does, and it earns its place — a spent token is the likeliest thing to go wrong in production, because a customer reloading the page causes one.

What ends a live session immediately is revocation, not expiry. Revoking the minting key with DELETE /api/v1/keys/{keyId}, or demoting the person behind it, cuts every embed it minted off on their next request — because that chain is re-read on every request rather than baked into a token at mint time.

What you are responsible for

  • Keeping the key on your server. Everything else here is designed around that one fact.
  • Deciding who gets which capabilities. Otava clamps what you ask for against what your key holds; it cannot know which of your customers should be allowed to publish. Mint per person, not per integration.
  • The origin list. It is your allowlist, and it is the only thing standing between a customer’s editor and a page you have never heard of.
  • The actor label. actor_label is free text you send, shown in the editor and written as the author of anything the embed does. Nothing branches on it — it is a caption, not an identity. The underlying principal stays the person who created the API key, so “Dana at Acme” on a version row is still traceable to a real Otava account.
  • Not relaying our refusals to your customers verbatim. A mint failure describes your key and the role of the person behind it, which is your business and not theirs.

Where this is today. The read path is live: the framed editor exchanges its token, draws the site’s state, its published history and the assistant’s transcript, and re-checks its own permissions every thirty seconds. The write controls are not. The chat composer, Publish, Undo and Discard post to the portal’s own endpoints — deliberately, so the embedded editor and the one at /editor/:siteId cannot drift apart — and those endpoints do not yet accept an embed session: they answer 401 and the frame reports the session as ended. So grant editor:view today; ask us at hello@otava.ai before you build on the rest, and we will tell you where it stands.

Client libraries

Two packages, one for each side of the story above. Neither is required, and nothing on this page assumes them: the API is HTTP with a bearer header, and the embed is an iframe with a token in its URL.

@otava/sdk — your server

import { Otava } from "@otava/sdk";

const otava = new Otava({ apiKey: process.env.OTAVA_API_KEY });

// Lists page themselves.
for await (const site of otava.sites.list()) {
  console.log(site.name, site.url);
}

// The token your page mounts the editor with — the only thing that leaves
// this server.
const embed = await otava.embed.createToken({
  site_id: siteId,
  capabilities: ["editor:view", "editor:chat"],
  origins: ["https://app.yourproduct.com"],
  actor_label: user.name,
});
res.json({ token: embed.token, site_id: embed.site_id });

// Long work hands back something to poll, not a held-open connection.
const change = await otava.changes.create({ site_id: siteId, instruction });
for await (const update of change) {
  console.log(update.stage, update.narration);
}

Its types are generated from the same zod schemas the handlers validate with — the same source as the OpenAPI document — so an endpoint that changes shape changes the client in the same commit, and the scope list, the capability vocabulary, the webhook events and the error registry are read from the server’s own modules rather than retyped. Lists are async-iterable and page themselves, long work comes back as a handle you can await or iterate, retries happen only when a failure could pass later and the request is safe to repeat, and verifyWebhook checks the timestamp before the HMAC over the raw bytes.

@otava/embed — your page

import { mountEditor } from "@otava/embed";

const editor = mountEditor("#slot", {
  token,
  siteId,
  onReady: ({ capabilities }) => console.log("editing with", capabilities),
  onPublished: ({ version }) => toast(`Published v${version}`),
  onError: async (error) => {
    if (error.recoverable) editor.refresh(await mintTokenFromMyServer());
    else showSupportMessage(error.message);
  },
});

About 4 kB gzipped, no dependencies, and it works from a script tag as well as a bundler. What it adds over the hand-rolled mount above is mostly the deadline that section asked you to write: every failure before the session exchange succeeds — a spent token, an expired one, a framing refusal — reaches your page as silence, because the frame does not yet know which origins it may post to. If the editor has not announced itself within twenty seconds the SDK says so itself, with recoverable: true and the likely cause, instead of leaving you an empty rectangle. It also normalises the origin you pass (a trailing slash would otherwise fail every comparison and produce a silently mute editor), sets the sandbox to five tokens with a stated reason for each, and tears the frame down cleanly.

Neither package is on npm yet. Mail hello@otava.ai if you want an early copy. Until then the raw versions above are the whole of what either does at the boundary, and they are what the examples in the repository run.

MCP, if your client is an AI

This API is for code you write. If what you actually want is Claude, ChatGPT or Cursor making the changes, Otava speaks MCP and that is a better fit — the same operations, described to a model, with a consent screen instead of a token in a config file.

  • https://otava.ai/api/mcp/<slug> — one site.
  • https://otava.ai/api/mcp — one connector covering the set of sites the owner ticks when they approve it.

Paste the URL into the app and it does the rest: the endpoint answers an unauthenticated request with the discovery challenge, the client registers itself (RFC 7591, no human pasting anything), and the owner approves it in a browser with PKCE. What a connector may do is capped by the role of whoever approved it, and recomputed on every call.

An API key is not an MCP credential. otv_sk_ keys are refused by the MCP endpoints — the account endpoint takes an OAuth access token minted for it, and a per-site endpoint takes that or the site’s own otv_live_ token. They are separate doors on purpose.

Versioning

/v1 is frozen. Changes to it are additive — a new endpoint, a new optional field, a new error code — and anything that would break a client written today goes in a /v2 instead. New fields can appear in a response at any time, so parse leniently and ignore what you do not recognise.

Something wrong or missing here? hello@otava.ai.