# Native developer docs > Developer docs for Native (native.no): the Norn API, which returns ranked post ideas for a brand, and the Native API, which lets a product run Native for its own customers over MCP. Neither is live yet: these pages describe the planned interfaces, and names and fields can change before access opens. Norn API access opens from a waitlist at https://native.no/meet-norn?from=docs#waitlist. To add one of these APIs to a project, follow its setup page for coding agents: [Norn API](https://docs.native.no/norn/agent-setup.md), [Native API](https://docs.native.no/native-api/agent-setup.md). Each page says what to check in the project, what to add and what to tell the person. Neither API is live yet, so the agent writes the code and calls nothing. --- # Norn API Source: https://docs.native.no/norn > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. Norn predicts which idea people will respond to, before anything is published. The API will let any product ask it for ranked ideas. > **Not live yet.** The Norn API opens from a [waitlist](https://native.no/meet-norn?from=docs#waitlist). These pages describe the interface we are building, so you can plan against it before your access opens. Names and fields can still change before access opens. ## An idea factory you can call Norn is the model behind the ideas Native suggests to its customers, more than 2,200 businesses. It does not write posts. It decides what a post should be: the point to make, the angle to take, the format, and why it fits now. The API opens that up to any product. Send Norn a brand, and it sends back ideas in order, best first. Each idea says why it should work now and cites where it came from, such as a page on the brand’s website or a customer review. The model you already use writes the post from there. ## Why not ask a language model? Language models are very good at writing. They are not good at knowing what to talk about. They are trained to predict the most likely next word, so their ideas drift toward the average, and the average is what people scroll past. Norn is built around a different question: which idea will stand out for this brand, with this audience, at this moment. It answers from what people actually do with ideas: which ones they approve, rewrite or turn down, and how the posts perform once they are out. We call that artificial intuition. ## What you send, what you get back 1. **A brand.** Its website, language and country, and any rules you set. Norn reads the website once and keeps what it learns. See [Brands](https://docs.native.no/norn/brands). 2. **A request for ideas.** How many you want, in which formats, and anything Norn should focus on. See [Ideas](https://docs.native.no/norn/ideas). 3. **Ranked ideas back.** Each has a hook, an angle, a reason it fits now, its sources, an outline and a prompt for a generative model. 4. **Feedback from you.** What people did with each idea and how the post performed. Norn uses it to learn the brand’s taste. See [Feedback](https://docs.native.no/norn/feedback). ## The calls | Call | What it does | Status | | --- | --- | --- | | [`POST /v1/brands`](https://docs.native.no/norn/brands) | Creates a brand from a website and anything you know about it. | Preview | | [`POST /v1/ideas`](https://docs.native.no/norn/ideas) | Starts a batch of ranked ideas for a brand. Fetch it with `GET /v1/ideas/{batch_id}` when it is done. | Preview | | [`POST /v1/feedback`](https://docs.native.no/norn/feedback) | Tells Norn what happened to an idea. | Preview | | [`POST /v1/predictions`](https://docs.native.no/norn/predictions) | Ranks drafts you already have, before they go out. | In development | ## How Norn learns Every idea Native suggests leaves a trail: the idea, what the customer did with it, and how the post performed. That trail is what Norn learns from. When customers turn ideas down and say why, Norn turns the reasons into lessons for that brand. When a post does better than the brand’s usual, Norn notes what made it work. The API is built to feed the same loop. The more you tell Norn about what happened to its ideas, the better the next ones fit. ## What is ready, and what is not - Brands, ideas and feedback make up the first preview. They are what Native runs on today, opened up as an API. - Predictions for drafts you already have are in development. The [Predictions](https://docs.native.no/norn/predictions) page shows the plan. - There is no SDK yet. The API is JSON over HTTPS, and every example comes in cURL, TypeScript and Python. - Pricing is not set yet. You will see the price before you are charged anything. ## Next steps - [Quickstart](https://docs.native.no/norn/quickstart): From your credentials to your first ranked ideas. - [Ideas](https://docs.native.no/norn/ideas): What an idea holds, and how to ask for good ones. - [Brands](https://docs.native.no/norn/brands): What Norn reads from a website, and how to correct it. - [FAQ](https://docs.native.no/norn/faq): What is live, what it costs, and how Norn differs from a chat model. --- # Set up with an agent Source: https://docs.native.no/norn/agent-setup > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. Give your coding agent one line, and it adds the Norn API to your project: the credentials, a token helper, a typed client and a check you can run. > **Not live yet.** The Norn API opens from a [waitlist](https://native.no/meet-norn?from=docs#waitlist). Your agent can write the code today, so it is ready when your access opens. Names and fields can still change before then. The line to give your coding agent: ```text Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md ``` Works in any coding agent that can read a web page. If yours cannot, use Copy page on the setup page and paste that in. Or start your agent with it, from your project folder: Claude Code: Claude Code asks before it reads docs.native.no. ```bash claude 'Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md' ``` Codex: By default Codex cannot fetch a new page. Approve its network request when it asks, start it with `codex --search`, or use Copy page on the setup page and paste that in. ```bash codex 'Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md' ``` Cursor CLI: Cursor asks before it reads docs.native.no. ```bash agent 'Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md' ``` Copilot CLI: Copilot asks before it reads docs.native.no. ```bash copilot -i 'Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md' ``` Gemini CLI: Gemini CLI asks before it reads docs.native.no. ```bash gemini -i 'Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md' ``` [Open in Cursor](https://cursor.com/link/prompt?text=Set%20up%20the%20Norn%20API%20in%20this%20project%20by%20following%20https%3A%2F%2Fdocs.native.no%2Fnorn%2Fagent-setup.md): opens Cursor’s chat with the line filled in, without sending it. - It adds `NATIVE_CLIENT_ID` and `NATIVE_CLIENT_SECRET` to your example env file, empty. You fill them in when Native issues your credentials. - It adds a token helper, a small typed client for brands, ideas and feedback, and a check you can run, in your project’s own language and style. - It never writes a secret into a file git tracks, and never puts one in browser or mobile code. - It does not call the Norn API, since access is not open yet. - Where these docs say nothing, it leaves a TODO and tells you, instead of guessing. - It asks before it installs a package, and tells you what it changed. Prefer to do it by hand? The [Quickstart](https://docs.native.no/norn/quickstart) goes through the same calls one at a time. ## Instructions for your agent If the person asked you to set up the Norn API, follow these steps in order, and keep to these rules throughout: - The Norn API is not live yet. Write the code, but make no call to `https://auth.native.no` or `https://api.native.no` until the person’s access opens. - Use only what these docs state: the endpoints, fields, values and addresses on these pages and on [Authentication](https://docs.native.no/authentication). When you need something they do not cover, leave a `TODO(norn-api)` comment saying what is missing, tell the person, and do not guess. - For more detail, read [llms-full.txt](https://docs.native.no/llms-full.txt), every page in one file. [llms.txt](https://docs.native.no/llms.txt) lists the pages, each with a Markdown copy. - Ask the person before you install a package the project does not already have. The Norn API needs nothing but an HTTP client. ## 1. Look at the project first Before you write anything, find out the language and framework, the package manager, where server-side code lives, where environment config lives and how it is loaded, and which HTTP client the project already uses. Write the code in the project’s own language, style and folders. If it already has Native code, for example from the Native API setup, build on that: reuse its credentials and token helper rather than adding a second copy. The Norn API is JSON over HTTPS, and there is no SDK. The samples below use TypeScript with `fetch` and Python with `requests`. The Python samples need Python 3.10 or later. In another language or client, keep the addresses, the environment variable names and the field names exactly as they are. If the project has no server side, such as a browser app, a mobile app or a static site, stop here and tell the person. The client secret must never reach a browser or a mobile app, so the calls need a backend to run from. In a repository with several apps, use the one with a server side, and say which you chose. ## 2. Add the credentials The Norn API and the Native API share one login: a client ID and secret that Native issues when access opens. Use exactly these names: .env.example: ```bash # Native login, shared by the Native API and the Norn API (https://docs.native.no/authentication). # Native issues these when your access opens. Never commit real values. NATIVE_CLIENT_ID= NATIVE_CLIENT_SECRET= ``` - Add both names, with empty values, to the project’s example env file. Create `.env.example` if there is none. - Write the empty names into a local env file only if git ignores it and does not track it. Check both: `git check-ignore ` must print the file’s name, and `git ls-files ` must print nothing. - If the local env file, such as `.env`, is tracked, do not write there, and do not edit `.gitignore` for it: that does not untrack it. Use a file git ignores and the app loads, such as `.env.local`, and name it in your summary. If you add a file to `.gitignore`, tell the person. - If the project checks its environment in code, add both as optional and server-only, so the app still starts while they are empty. - If the names are already there, leave them as they are. - Add no `NATIVE_ACCESS_TOKEN`. The [Quickstart](https://docs.native.no/norn/quickstart) reads a token from it to keep its examples short. Your code fetches tokens with the helper in the next step. ## 3. Add a token helper Follow [Authentication](https://docs.native.no/authentication#get-a-token): the client-credentials grant at `https://auth.native.no/oauth/token`, with `audience` set to `https://api.native.no/v1`. A token lasts at most an hour. Cache one token per audience, and fetch a new one a minute before `expires_in` runs out. Calls that arrive while a token is on its way share that request, so a busy backend fetches one token, not one per call. The Norn API’s `scope` is set when access opens, and these docs do not state it yet. Do not guess it. The client in the next step keeps it in a constant marked TODO, and the check stops until it is filled in. Put the helper in a server-only module. If the project already has it from the Native API setup, reuse it: one helper serves both APIs. TypeScript: ```ts const TOKEN_URL = 'https://auth.native.no/oauth/token' // Cache one token per API and fetch a new one a minute before it runs out. // Calls that arrive while a token is on its way share that request. const cache = new Map() const pending = new Map>() export async function accessToken(audience: string, scope: string): Promise { const key = `${audience} ${scope}` const hit = cache.get(key) if (hit && hit.expiresAt - Date.now() > 60_000) return hit.token let request = pending.get(key) if (!request) { request = requestToken(audience, scope) .then((entry) => { cache.set(key, entry) return entry.token }) .finally(() => pending.delete(key)) pending.set(key, request) } return request } async function requestToken(audience: string, scope: string) { const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.NATIVE_CLIENT_ID, client_secret: process.env.NATIVE_CLIENT_SECRET, audience, scope, }), }) if (!res.ok) throw new Error(`Token request failed: ${res.status}`) const body = (await res.json()) as { access_token: string; expires_in: number } return { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 } } ``` Python: ```python import os import time import requests TOKEN_URL = "https://auth.native.no/oauth/token" # Cache one token per API and fetch a new one a minute before it runs out. _cache: dict[str, tuple[str, float]] = {} def access_token(audience: str, scope: str) -> str: key = f"{audience} {scope}" hit = _cache.get(key) if hit and hit[1] - time.time() > 60: return hit[0] res = requests.post( TOKEN_URL, data={ "grant_type": "client_credentials", "client_id": os.environ["NATIVE_CLIENT_ID"], "client_secret": os.environ["NATIVE_CLIENT_SECRET"], "audience": audience, "scope": scope, }, timeout=30, ) res.raise_for_status() body = res.json() _cache[key] = (body["access_token"], time.time() + body["expires_in"]) return body["access_token"] ``` ## 4. Add a client for the calls Put every Norn API call in one server-only module, with a typed function for each. Take the types from the field lists: [the brand object](https://docs.native.no/norn/brands#brand-object), [the batch object](https://docs.native.no/norn/ideas#response), [the idea object](https://docs.native.no/norn/ideas#idea-object), and the Body list of each call. In Python the functions have the same names in snake_case. | Function | Call | What to know | | --- | --- | --- | | `createBrand` | `POST /v1/brands` | Answers at once, with `status` `reading` while Norn reads the website. | | `getBrand` | `GET /v1/brands/{brand_id}` | Returns the brand. | | `updateBrand` | `PATCH /v1/brands/{brand_id}` | Send only what changes. Lists such as `rules.never` are replaced, not merged. | | `waitForBrand` | Polls `getBrand` every 10 seconds | Until `status` is `ready`. `failed` means Norn could not read the website: create the brand again with `name` and `description`. | | `createIdeas` | `POST /v1/ideas` | Answers `202` with a batch that is `thinking`. The brand must be `ready`, or Norn answers `409` with the code `brand_not_ready`. | | `getBatch` | `GET /v1/ideas/{batch_id}` | Returns the batch. | | `waitForBatch` | Polls `getBatch` every 15 seconds | Until `status` is `done`, with the ideas in `data`, best first. On `failed`, `error` says why. In Native a batch usually takes two to six minutes. | | `sendFeedback` | `POST /v1/feedback` | Answers `202` with a receipt. Each request needs a `decision`, an `outcome` or both. Never send a reason the person did not give. | Keep to these rules in the client: - Never hold a request open while Norn thinks. Every call answers within seconds, so give each request a 30-second timeout, and ask for a brand or a batch again no more often than every 10 seconds. - Stop waiting after a time limit and throw, so a stuck brand or batch ends the job instead of waiting forever. The samples stop after 15 minutes. Run the waiting in a background job, never inside a web request. - On an error, throw one that carries the HTTP status and the `type`, `code` and `message` of [the error body](https://docs.native.no/norn/reference#errors). On `429`, wait as many seconds as `Retry-After` says before you try again. - Ignore fields you do not use. New ones can appear at any time. - Add nothing for [predictions](https://docs.native.no/norn/predictions). They are in development and not part of the first preview. - If the project has tests, test the client with HTTP mocked, using the responses shown on [Brands](https://docs.native.no/norn/brands) and [Ideas](https://docs.native.no/norn/ideas). A test never calls the real API. TypeScript: ```ts import { accessToken } from './native-token' const NORN_API = 'https://api.native.no/v1' export const NORN_AUDIENCE = 'https://api.native.no/v1' // TODO(norn-api): Native sets the scope when your access opens. The docs do not state it yet. export const NORN_SCOPE = '' // Stop waiting for a brand or a batch after this long. In Native a batch takes 2 to 6 minutes. const WAIT_LIMIT_MS = 15 * 60_000 export type Archetype = | 'innocent' | 'everyman' | 'hero' | 'outlaw' | 'explorer' | 'creator' | 'ruler' | 'magician' | 'lover' | 'caregiver' | 'jester' | 'sage' export type Format = 'image' | 'carousel' | 'video' | 'text' export type Channel = | 'instagram' | 'facebook' | 'linkedin' | 'tiktok' | 'x' | 'threads' | 'bluesky' | 'pinterest' | 'youtube' | 'other' export interface Rules { always: string[] never: string[] } // The body of POST /v1/brands. An update sends only what changes. export interface BrandInput { website?: string name?: string description?: string // up to 2,000 characters language?: string // ISO 639-1, such as 'en' country?: string // ISO 3166-1, such as 'US' archetype?: Archetype rules?: Partial // up to 20 of each } export interface Brand { id: string object: 'brand' status: 'reading' | 'ready' | 'failed' name: string | null website: string | null language: string | null country: string | null description: string | null offerings: string[] archetype: { value: Archetype; rationale: string | null } | null rules: Rules created_at: string } export interface IdeasInput { brand: string // a brand whose status is 'ready' count?: number // 1 to 10, default 3 formats?: Format[] focus?: string // up to 2,000 characters date?: string // YYYY-MM-DD, up to 45 days ahead } export interface Idea { id: string object: 'idea' rank: number title: string hook: string angle: string why_now: string format: Format sources: { type: 'website' | 'review' | 'product' | 'focus' title: string url: string | null quote: string | null }[] outline: { opening: string; key_points: string[]; call_to_action: string; visual: string } prompt: string } export interface IdeaBatch { id: string object: 'idea_batch' status: 'thinking' | 'done' | 'failed' model: string brand: string data: Idea[] left_out: string[] error: string | null created_at: string done_at: string | null } export interface FeedbackInput { idea: string decision?: 'approved' | 'edited' | 'rejected' final_text?: string // with 'edited': what was published reasons?: ('off_brand' | 'not_relevant' | 'wrong_language' | 'needs_work' | 'other')[] comment?: string // with 'rejected': the reason in the person's own words outcome?: { channel: Channel published_at: string views: number likes?: number comments?: number shares?: number clicks?: number } } export interface FeedbackReceipt { object: 'feedback' id: string idea: string created_at: string } export interface NornErrorBody { type: string code?: string message: string param?: string } export class NornError extends Error { status: number body: NornErrorBody | null retryAfter: number | null constructor(status: number, body: NornErrorBody | null, retryAfter: number | null) { super(body ? `Norn answered ${status} ${body.type}: ${body.message}` : `Norn answered ${status}`) this.status = status this.body = body this.retryAfter = retryAfter } } async function norn(method: 'GET' | 'POST' | 'PATCH', path: string, body?: object): Promise { const token = await accessToken(NORN_AUDIENCE, NORN_SCOPE) const res = await fetch(`${NORN_API}${path}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}), }, body: body ? JSON.stringify(body) : undefined, // Every call answers within seconds: the slow work runs in the background. signal: AbortSignal.timeout(30_000), }) if (!res.ok) { const parsed = (await res.json().catch(() => null)) as { error?: NornErrorBody } | null const retryAfter = Number(res.headers.get('Retry-After')) || null throw new NornError(res.status, parsed?.error ?? null, retryAfter) } return (await res.json()) as T } export const createBrand = (input: BrandInput) => norn('POST', '/brands', input) export const getBrand = (brandId: string) => norn('GET', `/brands/${brandId}`) export const updateBrand = (brandId: string, changes: BrandInput) => norn('PATCH', `/brands/${brandId}`, changes) export const createIdeas = (input: IdeasInput) => norn('POST', '/ideas', input) export const getBatch = (batchId: string) => norn('GET', `/ideas/${batchId}`) export const sendFeedback = (input: FeedbackInput) => norn('POST', '/feedback', input) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) // Asks again while Norn is still working, and gives up after the time limit. async function poll( get: () => Promise, everyMs: number, limitMs: number, ): Promise { const giveUpAt = Date.now() + limitMs let item = await get() while (item.status === 'reading' || item.status === 'thinking') { if (Date.now() + everyMs > giveUpAt) { throw new Error(`Norn is still ${item.status} after ${limitMs / 60_000} minutes`) } await sleep(everyMs) item = await get() } return item } // Waits until Norn has read the brand's website. Run it in a background job. export async function waitForBrand(brandId: string, limitMs = WAIT_LIMIT_MS): Promise { const brand = await poll(() => getBrand(brandId), 10_000, limitMs) // Norn could not read the website: create the brand again with name and description. if (brand.status === 'failed') throw new Error(`Norn could not read ${brand.website}`) return brand } // Waits until a batch is done. The ideas are in data, best first. Run it in a background job. export async function waitForBatch(batchId: string, limitMs = WAIT_LIMIT_MS): Promise { const batch = await poll(() => getBatch(batchId), 15_000, limitMs) if (batch.status === 'failed') throw new Error(`Norn could not finish the batch: ${batch.error}`) return batch } ``` Python: ```python import time from collections.abc import Callable from typing import Any, Literal, TypedDict, TypeVar import requests from native_token import access_token NORN_API = "https://api.native.no/v1" NORN_AUDIENCE = "https://api.native.no/v1" # TODO(norn-api): Native sets the scope when your access opens. The docs do not state it yet. NORN_SCOPE = "" # Stop waiting for a brand or a batch after this long. In Native a batch takes 2 to 6 minutes. WAIT_LIMIT_S = 15 * 60 Archetype = Literal[ "innocent", "everyman", "hero", "outlaw", "explorer", "creator", "ruler", "magician", "lover", "caregiver", "jester", "sage", ] Format = Literal["image", "carousel", "video", "text"] Channel = Literal[ "instagram", "facebook", "linkedin", "tiktok", "x", "threads", "bluesky", "pinterest", "youtube", "other", ] class Rules(TypedDict): always: list[str] never: list[str] class BrandArchetype(TypedDict): value: Archetype rationale: str | None class Brand(TypedDict): id: str object: Literal["brand"] status: Literal["reading", "ready", "failed"] name: str | None website: str | None language: str | None country: str | None description: str | None offerings: list[str] archetype: BrandArchetype | None rules: Rules created_at: str class Source(TypedDict): type: Literal["website", "review", "product", "focus"] title: str url: str | None quote: str | None class Outline(TypedDict): opening: str key_points: list[str] call_to_action: str visual: str class Idea(TypedDict): id: str object: Literal["idea"] rank: int title: str hook: str angle: str why_now: str format: Format sources: list[Source] outline: Outline prompt: str class IdeaBatch(TypedDict): id: str object: Literal["idea_batch"] status: Literal["thinking", "done", "failed"] model: str brand: str data: list[Idea] left_out: list[str] error: str | None created_at: str done_at: str | None # The body of POST /v1/brands. An update sends only what changes. class BrandInput(TypedDict, total=False): website: str name: str description: str # up to 2,000 characters language: str # ISO 639-1, such as "en" country: str # ISO 3166-1, such as "US" archetype: Archetype rules: dict[str, list[str]] # "always" and "never", up to 20 of each # Required keys sit in a base class, so this runs on Python 3.10, which has no typing.Required. class _IdeasRequired(TypedDict): brand: str # a brand whose status is "ready" class IdeasInput(_IdeasRequired, total=False): count: int # 1 to 10, default 3 formats: list[Format] focus: str # up to 2,000 characters date: str # YYYY-MM-DD, up to 45 days ahead class _OutcomeRequired(TypedDict): channel: Channel published_at: str views: int class Outcome(_OutcomeRequired, total=False): likes: int comments: int shares: int clicks: int class _FeedbackRequired(TypedDict): idea: str class FeedbackInput(_FeedbackRequired, total=False): decision: Literal["approved", "edited", "rejected"] final_text: str # with "edited": what was published reasons: list[Literal["off_brand", "not_relevant", "wrong_language", "needs_work", "other"]] comment: str # with "rejected": the reason in the person's own words outcome: Outcome class FeedbackReceipt(TypedDict): object: Literal["feedback"] id: str idea: str created_at: str class NornError(Exception): def __init__(self, status: int, body: dict[str, Any] | None, retry_after: int | None) -> None: super().__init__(f"Norn answered {status}: {body}") self.status = status self.body = body # type, code, message and param self.retry_after = retry_after def _norn(method: str, path: str, body: Any = None) -> Any: token = access_token(NORN_AUDIENCE, NORN_SCOPE) res = requests.request( method, NORN_API + path, headers={"Authorization": f"Bearer {token}"}, json=body, # Every call answers within seconds: the slow work runs in the background. timeout=30, ) if not res.ok: try: error = res.json().get("error") except ValueError: error = None retry_after = res.headers.get("Retry-After") raise NornError(res.status_code, error, int(retry_after) if retry_after else None) return res.json() def create_brand(fields: BrandInput) -> Brand: return _norn("POST", "/brands", fields) def get_brand(brand_id: str) -> Brand: return _norn("GET", f"/brands/{brand_id}") def update_brand(brand_id: str, changes: BrandInput) -> Brand: return _norn("PATCH", f"/brands/{brand_id}", changes) def create_ideas(request: IdeasInput) -> IdeaBatch: return _norn("POST", "/ideas", request) def get_batch(batch_id: str) -> IdeaBatch: return _norn("GET", f"/ideas/{batch_id}") def send_feedback(feedback: FeedbackInput) -> FeedbackReceipt: return _norn("POST", "/feedback", feedback) Item = TypeVar("Item", Brand, IdeaBatch) def _poll(get: Callable[[], Item], every_s: int, limit_s: int) -> Item: # Asks again while Norn is still working, and gives up after the time limit. give_up_at = time.monotonic() + limit_s item = get() while item["status"] in ("reading", "thinking"): if time.monotonic() + every_s > give_up_at: raise TimeoutError(f"Norn is still working after {limit_s // 60} minutes") time.sleep(every_s) item = get() return item # Waits until Norn has read the brand's website. Run it in a background job. def wait_for_brand(brand_id: str, limit_s: int = WAIT_LIMIT_S) -> Brand: brand = _poll(lambda: get_brand(brand_id), 10, limit_s) if brand["status"] == "failed": # Norn could not read the website: create the brand again with name and description. raise RuntimeError(f"Norn could not read {brand['website']}") return brand # Waits until a batch is done. The ideas are in data, best first. Run it in a background job. def wait_for_batch(batch_id: str, limit_s: int = WAIT_LIMIT_S) -> IdeaBatch: batch = _poll(lambda: get_batch(batch_id), 15, limit_s) if batch["status"] == "failed": raise RuntimeError(f"Norn could not finish the batch: {batch['error']}") return batch ``` ## 5. Add a check the person can run Add a script the person runs once their credentials are in, wired to the way the project runs scripts. Load the env file the way the app does, so the check reads the same values. The check: - Names each missing variable plainly and stops, before any network call. It stops the same way while the scope is still a TODO. - Otherwise fetches a token and makes one read-only call: `GET /v1/brands/{brand_id}`, for a brand id the person passes, or a made-up one. With a made-up id, Norn’s own `not_found` error body still shows that Norn answered. - Passes only on a brand or on Norn’s own error bodies, as [the reference](https://docs.native.no/norn/reference#errors) defines them: `404` with the type `not_found`, or `403` with `permission_error`. Every other error fails the check: a `404` or `403` page can also come from something in front of Norn, or from a wrong address. - On a failure, says what failed: the token request’s HTTP status, or Norn’s error. It never prints the secret or the token. - Creates, changes and sends nothing: no brand, no ideas, no feedback. TypeScript: ```ts import { accessToken } from './native-token' import { getBrand, NORN_AUDIENCE, NORN_SCOPE, NornError } from './norn-api' // True only for an error body Norn itself sent. A 404 or 403 page from anything // in front of Norn, or from a wrong address, has no such body. function nornSaid(err: unknown, status: number, type: string): boolean { return err instanceof NornError && err.status === status && err.body?.type === type } async function main(): Promise { const missing = ['NATIVE_CLIENT_ID', 'NATIVE_CLIENT_SECRET'].filter((name) => !process.env[name]) if (missing.length > 0) { console.log(`Norn API: ${missing.join(' and ')} not set. Native issues them when your access opens.`) return 1 } if (!NORN_SCOPE) { console.log('Norn API: NORN_SCOPE is still a TODO. Native sets the scope when your access opens.') return 1 } try { await accessToken(NORN_AUDIENCE, NORN_SCOPE) } catch (err) { console.error(`Norn API: no token. ${(err as Error).message}`) return 1 } // Read only. With a made-up id, Norn's own not_found error body still shows that Norn answered. const brandId = process.argv[2] ?? 'brand_check' try { const brand = await getBrand(brandId) console.log(`Norn API: signed in. Brand ${brand.id} is ${brand.status}.`) } catch (err) { if (nornSaid(err, 404, 'not_found')) { console.log('Norn API: signed in. No brand has that id, which is fine for this check.') } else if (nornSaid(err, 403, 'permission_error')) { console.log('Norn API: signed in, but these credentials cannot reach that brand.') } else { // Every other error fails the check. console.error(`Norn API check failed: ${(err as Error).message}`) return 1 } } return 0 } main().then((code) => process.exit(code)) ``` Python: ```python import os import sys from native_token import access_token from norn_api import NORN_AUDIENCE, NORN_SCOPE, NornError, get_brand def norn_said(err: NornError, status: int, error_type: str) -> bool: # True only for an error body Norn itself sent. A 404 or 403 page from anything # in front of Norn, or from a wrong address, has no such body. return ( err.status == status and isinstance(err.body, dict) and err.body.get("type") == error_type ) def main() -> int: missing = [n for n in ("NATIVE_CLIENT_ID", "NATIVE_CLIENT_SECRET") if not os.environ.get(n)] if missing: print(f"Norn API: {' and '.join(missing)} not set. Native issues them when your access opens.") return 1 if not NORN_SCOPE: print("Norn API: NORN_SCOPE is still a TODO. Native sets the scope when your access opens.") return 1 try: access_token(NORN_AUDIENCE, NORN_SCOPE) except Exception as err: print(f"Norn API: no token. {err}") return 1 # Read only. With a made-up id, Norn's own not_found error body still shows that Norn answered. brand_id = sys.argv[1] if len(sys.argv) > 1 else "brand_check" try: brand = get_brand(brand_id) except NornError as err: if norn_said(err, 404, "not_found"): print("Norn API: signed in. No brand has that id, which is fine for this check.") return 0 if norn_said(err, 403, "permission_error"): print("Norn API: signed in, but these credentials cannot reach that brand.") return 0 # Every other error fails the check. print(f"Norn API check failed: {err}") return 1 except Exception as err: print(f"Norn API check failed: {err}") return 1 print(f"Norn API: signed in. Brand {brand['id']} is {brand['status']}.") return 0 if __name__ == "__main__": sys.exit(main()) ``` If you can run commands here and the variables are still empty, run it once: it must name both and stop before any network call. Leave the first real run to the person. ## 6. Point future sessions at the docs If the project has agent instructions, such as `AGENTS.md`, `CLAUDE.md` or a rule file in `.cursor/rules/`, add the line below, with the real path, to an existing general rule: a file or rule that applies to the whole project. Skip this step when none fits, and do not create a new file for it. ```text - Norn API: docs for coding agents at https://docs.native.no/llms.txt. Our client is in . ``` ## 7. Tell the person what changed Finish with a short summary for the person: - The files you added or changed, and any package you installed with their approval. - What to fill in when access opens: `NATIVE_CLIENT_ID` and `NATIVE_CLIENT_SECRET`, with the name of the ignored local env file they go in, and the scope in `NORN_SCOPE`. - How to run the check, and that nothing has called the Norn API yet. - Every TODO you left, and why. - How access opens. The Norn API opens from the [waitlist](https://native.no/meet-norn?from=docs#waitlist): the person joins it, and Native emails them when their access is ready. --- # Quickstart Source: https://docs.native.no/norn/quickstart > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. From your credentials to your first ranked ideas: create a brand from a website, ask for ideas, turn one into a post and tell Norn what happened. > Rather have your coding agent set it up? Give it the one line on [Set up with an agent](https://docs.native.no/norn/agent-setup), and it adds the Norn API to your project: the credentials, a token helper, a typed client and a check you can run. Five steps. Two of them wait on Norn: reading the website, once per brand, and thinking up each batch of ideas, which usually takes a few minutes. ## 1. Get an access token Join the [waitlist](https://native.no/meet-norn?from=docs#waitlist). When your access is ready, you get a client ID and secret: the same pair signs you in to the Norn API and the Native API. Your backend trades them for an access token that lasts up to an hour, with the Norn API as its `audience`. [Authentication](https://docs.native.no/authentication) shows the call. The examples read the token from an environment variable: ```bash export NATIVE_ACCESS_TOKEN="the access_token you got back" ``` ## 2. Create a brand A brand is who Norn suggests ideas for. Give it a website, and Norn reads the site to learn what the business does, who it is for and how it sounds. cURL: ```bash curl -X POST https://api.native.no/v1/brands \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "website": "https://pipedream.example", "country": "US" }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/brands', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ website: "https://pipedream.example", country: "US" }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/brands", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "website": "https://pipedream.example", "country": "US" }, timeout=30, ) res.raise_for_status() result = res.json() ``` Reading a website takes a few minutes, so the brand comes back with `"status": "reading"`. Ask for it again until the status is `ready`: cURL: ```bash curl https://api.native.no/v1/brands/brand_7Hk2pQ \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/brands/brand_7Hk2pQ', { headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, }, }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.get( "https://api.native.no/v1/brands/brand_7Hk2pQ", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, timeout=30, ) res.raise_for_status() result = res.json() ``` Response: ```json { "id": "brand_7Hk2pQ", "object": "brand", "status": "ready", "name": "Pipe Dream Plumbing", "website": "https://pipedream.example", "language": "en", "country": "US", "description": "Family plumbers in Columbus, Ohio. Same-day callouts, seven days a week.", "offerings": [ "Emergency repairs", "Water heater installs", "Drain cleaning" ], "archetype": { "value": "jester", "rationale": "The website jokes about leaks and calls the team the drip squad. The humour always lands on a promise: they show up fast." }, "rules": { "always": [ "Mention same-day callouts" ], "never": [ "Joke at the customer’s expense" ] }, "created_at": "2026-10-11T09:12:40Z" } ``` Check the `archetype` and its `rationale`. If Norn read the brand wrong, [correct it](https://docs.native.no/norn/brands#update) before you ask for ideas. ## 3. Ask for ideas Send the brand’s id. `count` is how many ideas you want back, and `focus` is anything Norn should lean toward, in plain words. cURL: ```bash curl -X POST https://api.native.no/v1/ideas \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "brand": "brand_7Hk2pQ", "count": 2, "focus": "We want more emergency callouts this winter." }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/ideas', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ brand: "brand_7Hk2pQ", count: 2, focus: "We want more emergency callouts this winter." }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/ideas", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "brand": "brand_7Hk2pQ", "count": 2, "focus": "We want more emergency callouts this winter." }, timeout=30, ) res.raise_for_status() result = res.json() ``` Norn looks through the brand’s material and history before it answers, which takes a few minutes. So you get a batch back at once, with `"status": "thinking"`. Ask for the batch every 15 seconds or so: cURL: ```bash curl https://api.native.no/v1/ideas/batch_3Kp9sD \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/ideas/batch_3Kp9sD', { headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, }, }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.get( "https://api.native.no/v1/ideas/batch_3Kp9sD", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, timeout=30, ) res.raise_for_status() result = res.json() ``` When its status is `done`, the ideas are in `data`, ranked best first: Response: ```json { "id": "batch_3Kp9sD", "object": "idea_batch", "status": "done", "model": "norn-preview", "brand": "brand_7Hk2pQ", "data": [ { "id": "idea_4Fd9xW", "object": "idea", "rank": 1, "title": "The Sunday frozen-pipe rescue", "hook": "Frozen pipes do not care that it is Sunday. We do.", "angle": "Deadpan humour about the worst-timed emergency, ending on proof that the team shows up the same day.", "why_now": "Winter is starting in Columbus, and burst pipes come with the first hard frosts.", "format": "video", "sources": [ { "type": "website", "title": "Emergency plumbing", "url": "https://pipedream.example/emergency", "quote": "Same-day callouts, seven days a week." }, { "type": "review", "title": "Review from a customer in Clintonville", "url": null, "quote": "They came on a Sunday and had the water back on in an hour." }, { "type": "focus", "title": "Your focus", "url": null, "quote": "We want more emergency callouts this winter." } ], "outline": { "opening": "Frozen pipes do not care that it is Sunday. We do.", "key_points": [ "A burst pipe from a real Sunday callout, before and after", "Running water again within the hour" ], "call_to_action": "Book a same-day visit at pipedream.example.", "visual": "A 15-second vertical video: the split pipe, a deadpan look to camera, then the repaired pipe with the water running." }, "prompt": "Write a 15-second vertical video script for Pipe Dream Plumbing, a family plumber in Columbus, Ohio. Voice: dry and joking, never at the customer’s expense. Open with: \"Frozen pipes do not care that it is Sunday. We do.\" Show a burst pipe from a real Sunday callout, then the repair and the water running again. Say that they come out the same day, seven days a week. End with: \"Book a same-day visit at pipedream.example.\"" }, { "id": "idea_9Qm3vT", "object": "idea", "rank": 2, "title": "Guess the leak", "hook": "One of these three drips is quietly running up your water bill. Which one?", "angle": "A quiz built from real callouts that teaches people to spot the costly leak, with the answer on the last slide.", "why_now": "Evergreen", "format": "carousel", "sources": [ { "type": "website", "title": "Leak detection", "url": "https://pipedream.example/leaks", "quote": "Most leaks we find have been dripping for months." } ], "outline": { "opening": "One of these three drips is quietly running up your water bill. Which one?", "key_points": [ "Three close-ups from real callouts: a tap, a toilet, a pipe joint", "The answer, and why it is the one that costs the most" ], "call_to_action": "Not sure about yours? Book a leak check.", "visual": "Four slides: three close-up photos of drips, then the answer." }, "prompt": "Write a four-slide Instagram carousel for Pipe Dream Plumbing, a family plumber in Columbus, Ohio. Voice: dry and joking, never at the customer’s expense. Slide 1 asks: \"One of these three drips is quietly running up your water bill. Which one?\" Slides 2 to 4 show a dripping tap, a running toilet and a weeping pipe joint from real callouts. The last slide reveals the answer and explains why it costs the most. End with: \"Not sure about yours? Book a leak check.\"" } ], "left_out": [ "A post about water heater rebates: nothing on the website or in your focus backs it." ], "error": null, "created_at": "2026-10-11T09:20:00Z", "done_at": "2026-10-11T09:24:12Z" } ``` ## 4. Turn an idea into a post Norn decides what the post should be. The writing is a job for a generative model. Every idea carries a `prompt` written for exactly that, so you can hand it to the model you already use: TypeScript: ```ts import Anthropic from '@anthropic-ai/sdk' const anthropic = new Anthropic() // reads ANTHROPIC_API_KEY const [best] = result.data // Any text model works. This example uses Claude. const message = await anthropic.messages.create({ model: 'claude-sonnet-5-5', max_tokens: 1024, messages: [{ role: 'user', content: best.prompt }], }) const script = message.content.find((block) => block.type === 'text')?.text ``` Prefer to write it yourself, or to design it in your own tool? Use the `outline` instead: an opening, the key points, a call to action and a note on the visuals. ## 5. Tell Norn what happened When someone approves, rewrites or turns down an idea, say so. Here, the team posted the Sunday idea with a new last line: cURL: ```bash curl -X POST https://api.native.no/v1/feedback \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "idea": "idea_4Fd9xW", "decision": "edited", "final_text": "Frozen pipes do not care that it is Sunday. Neither do we: same-day fix, no drama." }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/feedback', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ idea: "idea_4Fd9xW", decision: "edited", final_text: "Frozen pipes do not care that it is Sunday. Neither do we: same-day fix, no drama." }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/feedback", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "idea": "idea_4Fd9xW", "decision": "edited", "final_text": "Frozen pipes do not care that it is Sunday. Neither do we: same-day fix, no drama." }, timeout=30, ) res.raise_for_status() result = res.json() ``` Once the post has been out for a few days, send its numbers as well. [Feedback](https://docs.native.no/norn/feedback) covers outcomes, reasons and what Norn does with them. > **Building with a coding agent?.** Give it [llms-full.txt](https://docs.native.no/llms-full.txt): every page of these docs as plain text, ready to read in one go. ## Where to go next - [Ideas](https://docs.native.no/norn/ideas): Every field of an idea, and every option you can ask with. - [Patterns](https://docs.native.no/norn/patterns): An idea feed, a first week of posts, a person in the loop. - [Feedback](https://docs.native.no/norn/feedback): How decisions and results teach Norn a brand’s taste. - [API reference](https://docs.native.no/norn/reference): Base URL, authentication, errors and limits. --- # Brands Source: https://docs.native.no/norn/brands > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. A brand is who Norn suggests ideas for. Create one from a website, check what Norn read, and correct anything it got wrong. Every idea is for a brand. A brand holds what Norn knows about one business or person: what it does, who it is for, how it sounds and what it must never say. Feedback is kept per brand too, so each brand builds up its own taste. If you serve many businesses, such as an agency with clients or a platform with customers, create one brand for each of them. ## What Norn reads from a website Give Norn a website and it reads the pages it can reach: what the business offers, its products, what customers say about it and how it talks. From that it writes a short description, lists the offerings and suggests an archetype. Ideas later cite these pages as their sources. Norn reads the site when you create the brand. Change `website` in an update to make it read again, for example after a relaunch. ## The archetype Norn describes a brand’s personality as one of twelve archetypes, drawn from Carl Jung and set out for brands by Margaret Mark and Carol Pearson. The archetype sets the brand’s voice, and the voice decides which angles an idea can take. A jester can joke about a burst pipe. A sage explains why pipes burst. | Archetype | Sounds | | --- | --- | | `innocent` | Simple, honest and hopeful. | | `everyman` | Down to earth. One of us. | | `hero` | Brave and determined. Proves itself. | | `outlaw` | Breaks the rules. Says what others will not. | | `explorer` | Curious and free. Looks for what is new. | | `creator` | Inventive. Cares about craft. | | `ruler` | In control. Sets the standard. | | `magician` | Transforms things. Makes the hard look easy. | | `lover` | Warm and close. About beauty and feeling. | | `caregiver` | Kind and protective. Looks after people. | | `jester` | Playful and funny. Never takes itself too seriously. | | `sage` | Wise. Explains, and is trusted for it. | If you know the archetype, set it. If you leave it out, Norn suggests one and explains its choice in `archetype.rationale`. Read the rationale once: a brand with the wrong voice gets the wrong ideas. ## Create a brand `POST /v1/brands` (preview) Body: - `website` (string, optional): The brand’s website, starting with `https://`. Leave it out only when you send `name` and `description` instead. - `name` (string, optional): The brand’s name. Norn takes it from the website when you leave it out. - `description` (string, optional): What the business does, in a sentence or two. Useful when there is no website. Up to 2,000 characters. - `language` (string, optional): The language ideas are written in, as an ISO 639-1 code such as `en` or `nb`. Norn takes it from the website when you leave it out. - `country` (string, optional): Where the brand is, as an ISO 3166-1 code such as `US` or `NO`. Norn uses it for the calendar. It knows the holidays of the US, the UK, Norway, Australia, Canada, New Zealand and Ireland, and goes by the season elsewhere. - `archetype` (string, optional; one of: `innocent`, `everyman`, `hero`, `outlaw`, `explorer`, `creator`, `ruler`, `magician`, `lover`, `caregiver`, `jester`, `sage`): The brand’s archetype. Norn suggests one when you leave it out. - `rules` (object, optional): Instructions every idea follows. Norn never overrides them with what it learns from feedback. - `always` (string[], optional): Things every idea should do, such as "Mention same-day callouts". Up to 20. - `never` (string[], optional): Things no idea may do, such as "Mention prices". Up to 20. cURL: ```bash curl -X POST https://api.native.no/v1/brands \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "website": "https://pipedream.example", "country": "US" }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/brands', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ website: "https://pipedream.example", country: "US" }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/brands", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "website": "https://pipedream.example", "country": "US" }, timeout=30, ) res.raise_for_status() result = res.json() ``` The brand comes back straight away, while Norn reads the website: Response: ```json { "id": "brand_7Hk2pQ", "object": "brand", "status": "reading", "name": null, "website": "https://pipedream.example", "language": null, "country": "US", "description": null, "offerings": [], "archetype": null, "rules": { "always": [], "never": [] }, "created_at": "2026-10-11T09:12:40Z" } ``` ## Get a brand `GET /v1/brands/{brand_id}` (preview) Returns the brand. While `status` is `reading`, ask again every ten seconds or so. When Norn could not read the website, for example because it blocks visitors, the status is `failed`: create the brand again with `name` and `description` instead. cURL: ```bash curl https://api.native.no/v1/brands/brand_7Hk2pQ \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/brands/brand_7Hk2pQ', { headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, }, }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.get( "https://api.native.no/v1/brands/brand_7Hk2pQ", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, timeout=30, ) res.raise_for_status() result = res.json() ``` Response: ```json { "id": "brand_7Hk2pQ", "object": "brand", "status": "ready", "name": "Pipe Dream Plumbing", "website": "https://pipedream.example", "language": "en", "country": "US", "description": "Family plumbers in Columbus, Ohio. Same-day callouts, seven days a week.", "offerings": [ "Emergency repairs", "Water heater installs", "Drain cleaning" ], "archetype": { "value": "jester", "rationale": "The website jokes about leaks and calls the team the drip squad. The humour always lands on a promise: they show up fast." }, "rules": { "always": [ "Mention same-day callouts" ], "never": [ "Joke at the customer’s expense" ] }, "created_at": "2026-10-11T09:12:40Z" } ``` ## Update a brand `PATCH /v1/brands/{brand_id}` (preview) Send only what you want to change. It takes the same fields as creating a brand. Lists, such as `rules.never`, are replaced rather than merged. A new `website` makes Norn read the site again. cURL: ```bash curl -X PATCH https://api.native.no/v1/brands/brand_7Hk2pQ \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "archetype": "jester", "rules": { "always": [ "Mention same-day callouts" ], "never": [ "Joke at the customer’s expense", "Mention prices" ] } }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/brands/brand_7Hk2pQ', { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ archetype: "jester", rules: { always: [ "Mention same-day callouts" ], never: [ "Joke at the customer’s expense", "Mention prices" ] } }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.patch( "https://api.native.no/v1/brands/brand_7Hk2pQ", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "archetype": "jester", "rules": { "always": [ "Mention same-day callouts" ], "never": [ "Joke at the customer’s expense", "Mention prices" ] } }, timeout=30, ) res.raise_for_status() result = res.json() ``` ## The brand object Fields: - `id` (string, required): Starts with `brand_`. - `object` (string, required): Always `brand`. - `status` (string, required; one of: `reading`, `ready`, `failed`): You can ask for ideas once it is `ready`. - `name` (string | null, required): `null` until Norn has read the website. - `website` (string | null, required): The website Norn read. - `language` (string | null, required): ISO 639-1 code. - `country` (string | null, required): ISO 3166-1 code. - `description` (string | null, required): What Norn understood the business to be. - `offerings` (string[], required): What the business sells or does, as Norn read it. - `archetype` (object | null, required): The brand’s archetype. - `value` (string, required; one of: `innocent`, `everyman`, `hero`, `outlaw`, `explorer`, `creator`, `ruler`, `magician`, `lover`, `caregiver`, `jester`, `sage`): The archetype. - `rationale` (string | null, required): Why Norn chose it. `null` when you set the archetype yourself. - `rules` (object, required): Instructions every idea follows. Norn never overrides them with what it learns from feedback. - `always` (string[], optional): Things every idea should do, such as "Mention same-day callouts". Up to 20. - `never` (string[], optional): Things no idea may do, such as "Mention prices". Up to 20. - `created_at` (string, required): When the brand was created, in ISO 8601. --- # Ideas Source: https://docs.native.no/norn/ideas > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. Ask Norn for ideas and get them back ranked, each with a hook, an angle, why it fits now, its sources and a prompt for a generative model. An idea is the decision before the writing. It says what a post should be about, how to come at it, which format suits it and why now. It is not the finished post: Norn leaves the writing to whichever model does it best. ## What an idea holds | Field | What it is | | --- | --- | | `rank` | Norn’s order. `1` is the idea it would post first. | | `title` | A short name for the idea, for lists and dashboards. | | `hook` | The opening line: the words that make someone stop scrolling. | | `angle` | The take on the subject, and why it suits this audience. | | `why_now` | Why the idea fits this moment: a season, a date, something new at the business. `Evergreen` when it works any time. | | `format` | The format the idea is meant for. | | `sources` | Where the idea comes from, with the words it relies on quoted. | | `outline` | An opening, the key points, a call to action and a note on the visuals. | | `prompt` | The whole idea, written as instructions for a generative model. | ## Every idea shows its sources Ideas are built from what the brand’s own material says. When an idea makes a claim about the business, it cites the page, review or product the claim comes from, and quotes the words. A claim you cannot trace is a claim you cannot post, so this is what lets a person, or your code, check an idea before it goes out. When Norn cannot back an idea, it leaves the idea out and says so in `left_out`. That list is worth reading: it often names a page the website is missing. ## Formats | Format | Meant for | | --- | --- | | `image` | One picture with a caption. | | `carousel` | Two to ten slides that tell a story or teach something step by step. | | `video` | A short vertical video, such as a reel. | | `text` | Words only, such as a LinkedIn or Threads post. | Ideas are not tied to a channel. The same idea can become a reel on Instagram and a text post on LinkedIn: write one post per channel from it. ## Create ideas `POST /v1/ideas` (preview) Body: - `brand` (string, required): The id of a brand whose `status` is `ready`. - `count` (integer, optional; default: `3`): How many ideas to return, from 1 to 10. - `formats` (string[], optional; one of: `image`, `carousel`, `video`, `text`): Only suggest ideas in these formats. All formats when you leave it out. - `focus` (string, optional): Anything Norn should lean toward, in plain words: a campaign, a launch, a goal for the month. Up to 2,000 characters. Ideas built on it cite it as a source. - `date` (string, optional; default: `today`): Plan for another day, as `YYYY-MM-DD`, up to 45 days ahead. `why_now` is written for that day. Norn looks through the brand’s material and history before it answers. In Native that usually takes two to six minutes, far longer than an HTTP request should stay open. So the request answers at once with a batch that is still thinking, and the ideas arrive in the same batch when Norn is done. cURL: ```bash curl -X POST https://api.native.no/v1/ideas \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "brand": "brand_7Hk2pQ", "count": 2, "focus": "We want more emergency callouts this winter." }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/ideas', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ brand: "brand_7Hk2pQ", count: 2, focus: "We want more emergency callouts this winter." }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/ideas", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "brand": "brand_7Hk2pQ", "count": 2, "focus": "We want more emergency callouts this winter." }, timeout=30, ) res.raise_for_status() result = res.json() ``` Norn answers `202 Accepted` with the batch: Response: ```json { "id": "batch_3Kp9sD", "object": "idea_batch", "status": "thinking", "model": "norn-preview", "brand": "brand_7Hk2pQ", "data": [], "left_out": [], "error": null, "created_at": "2026-10-11T09:20:00Z", "done_at": null } ``` ## Get a batch `GET /v1/ideas/{batch_id}` (preview) Returns the batch. While `status` is `thinking`, ask again every 15 seconds or so. When it is `done`, the ideas are in `data`, best first. When it is `failed`, `error` says why. cURL: ```bash curl https://api.native.no/v1/ideas/batch_3Kp9sD \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/ideas/batch_3Kp9sD', { headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, }, }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.get( "https://api.native.no/v1/ideas/batch_3Kp9sD", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, timeout=30, ) res.raise_for_status() result = res.json() ``` Response: ```json { "id": "batch_3Kp9sD", "object": "idea_batch", "status": "done", "model": "norn-preview", "brand": "brand_7Hk2pQ", "data": [ { "id": "idea_4Fd9xW", "object": "idea", "rank": 1, "title": "The Sunday frozen-pipe rescue", "hook": "Frozen pipes do not care that it is Sunday. We do.", "angle": "Deadpan humour about the worst-timed emergency, ending on proof that the team shows up the same day.", "why_now": "Winter is starting in Columbus, and burst pipes come with the first hard frosts.", "format": "video", "sources": [ { "type": "website", "title": "Emergency plumbing", "url": "https://pipedream.example/emergency", "quote": "Same-day callouts, seven days a week." }, { "type": "review", "title": "Review from a customer in Clintonville", "url": null, "quote": "They came on a Sunday and had the water back on in an hour." }, { "type": "focus", "title": "Your focus", "url": null, "quote": "We want more emergency callouts this winter." } ], "outline": { "opening": "Frozen pipes do not care that it is Sunday. We do.", "key_points": [ "A burst pipe from a real Sunday callout, before and after", "Running water again within the hour" ], "call_to_action": "Book a same-day visit at pipedream.example.", "visual": "A 15-second vertical video: the split pipe, a deadpan look to camera, then the repaired pipe with the water running." }, "prompt": "Write a 15-second vertical video script for Pipe Dream Plumbing, a family plumber in Columbus, Ohio. Voice: dry and joking, never at the customer’s expense. Open with: \"Frozen pipes do not care that it is Sunday. We do.\" Show a burst pipe from a real Sunday callout, then the repair and the water running again. Say that they come out the same day, seven days a week. End with: \"Book a same-day visit at pipedream.example.\"" }, { "id": "idea_9Qm3vT", "object": "idea", "rank": 2, "title": "Guess the leak", "hook": "One of these three drips is quietly running up your water bill. Which one?", "angle": "A quiz built from real callouts that teaches people to spot the costly leak, with the answer on the last slide.", "why_now": "Evergreen", "format": "carousel", "sources": [ { "type": "website", "title": "Leak detection", "url": "https://pipedream.example/leaks", "quote": "Most leaks we find have been dripping for months." } ], "outline": { "opening": "One of these three drips is quietly running up your water bill. Which one?", "key_points": [ "Three close-ups from real callouts: a tap, a toilet, a pipe joint", "The answer, and why it is the one that costs the most" ], "call_to_action": "Not sure about yours? Book a leak check.", "visual": "Four slides: three close-up photos of drips, then the answer." }, "prompt": "Write a four-slide Instagram carousel for Pipe Dream Plumbing, a family plumber in Columbus, Ohio. Voice: dry and joking, never at the customer’s expense. Slide 1 asks: \"One of these three drips is quietly running up your water bill. Which one?\" Slides 2 to 4 show a dripping tap, a running toilet and a weeping pipe joint from real callouts. The last slide reveals the answer and explains why it costs the most. End with: \"Not sure about yours? Book a leak check.\"" } ], "left_out": [ "A post about water heater rebates: nothing on the website or in your focus backs it." ], "error": null, "created_at": "2026-10-11T09:20:00Z", "done_at": "2026-10-11T09:24:12Z" } ``` ## The batch object Fields: - `id` (string, required): Starts with `batch_`. - `object` (string, required): Always `idea_batch`. - `status` (string, required; one of: `thinking`, `done`, `failed`): Where the batch is. Only a `done` batch has ideas. - `model` (string, required): The model that answered, such as `norn-preview`. - `brand` (string, required): The brand the ideas are for. - `data` (idea[], required): The ideas, best first. Empty until the batch is `done`. - `left_out` (string[], required): Ideas Norn considered and left out because nothing backed them. Often empty. - `error` (string | null, required): Why the batch failed. `null` otherwise. - `created_at` (string, required): When you asked, in ISO 8601. - `done_at` (string | null, required): When Norn finished, in ISO 8601. `null` while it is thinking. ## The idea object Fields: - `id` (string, required): Starts with `idea_`. Send it back with [feedback](https://docs.native.no/norn/feedback). - `object` (string, required): Always `idea`. - `rank` (integer, required): Norn’s order, from `1`. - `title` (string, required): A short name for the idea. - `hook` (string, required): The opening line. - `angle` (string, required): The take on the subject. - `why_now` (string, required): Why it fits the moment, or `Evergreen`. - `format` (string, required; one of: `image`, `carousel`, `video`, `text`): The format it is meant for. - `sources` (object[], required): Where the idea comes from. Up to four. - `type` (string, required; one of: `website`, `review`, `product`, `focus`): `focus` means the idea is built on what you sent in `focus`. - `title` (string, required): A name for the source, such as a page title. - `url` (string | null, required): Where to see it, when it is a page. - `quote` (string | null, required): The words the idea relies on, quoted exactly. - `outline` (object, required): The idea laid out for a writer or a designer. - `opening` (string, required): The first line. - `key_points` (string[], required): What the post has to get across, one to five points. - `call_to_action` (string, required): What the reader is asked to do. - `visual` (string, required): A note on the picture, the slides or the video. - `prompt` (string, required): The idea written as instructions for a generative model, in the brand’s voice and language. ## Languages Ideas are written in the brand’s `language`, whichever it is. Norn does not check that an idea came back in that language yet. If one did not, turn it down with the reason `wrong_language`, so Norn learns from it. --- # Feedback Source: https://docs.native.no/norn/feedback > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. Tell Norn what happened to an idea: approved, edited or turned down, and how the post performed. It is how Norn learns a brand’s taste. Norn learns a brand’s taste the way a good marketer does: by watching which ideas the people who know the brand keep, change or throw away, and what happens once posts go out. Feedback is how you show it. Every business on Native feeds this loop today. The API is built to feed it the same way, so the brands you create learn from your users’ decisions too. ## Decisions Send a decision when a person acts on an idea: | Decision | When | Send with it | | --- | --- | --- | | `approved` | The idea was used as it was. | Nothing else. | | `edited` | The idea was used, but changed. | `final_text`: what was actually posted. | | `rejected` | The idea was turned down. | `reasons`, and a `comment` if the person wrote one. | A reason in a person’s own words teaches Norn the most. From decisions, Norn writes lessons about what a brand’s posts should be about, such as "Posts about prices get turned down", and the ideas after that follow them. In Native, lessons start once a brand has eight explained decisions in 90 days (approvals, and turn-downs that came with a reason), and each lesson rests on at least three of them. In Native, a comment in the person’s own words about the idea or its wording also becomes a do or a don’t in the brand’s writing style, and one only about the picture becomes a picture rule. A reason picked from the list, with no comment, adds neither. ## Reasons | Reason | Means | | --- | --- | | `off_brand` | It does not sound like the brand. | | `not_relevant` | The subject does not matter to this brand or its audience. | | `wrong_language` | It came back in the wrong language. | | `needs_work` | The idea is fine, but the hook or the angle is weak. | | `other` | Anything else. Say what in `comment`. | Do not invent a reason when the person gave none. A rejection without a reason is still useful; a made-up reason teaches Norn the wrong lesson. ## Outcomes Once a post has been out for a few days, send its numbers as an `outcome`. Norn compares each post with the brand’s own usual results, never with other brands, so a corner bakery and a national chain are each judged on their own scale. Engagement is counted the way Native counts it: likes, comments, shares and clicks, divided by views. Send the numbers your channel reports and Norn does the rest. ## Send feedback `POST /v1/feedback` (preview) Body: - `idea` (string, required): The id of the idea. - `decision` (string, optional; one of: `approved`, `edited`, `rejected`): What a person did with the idea. Each request needs a `decision`, an `outcome` or both. - `final_text` (string, optional): With `edited`: the text that was published. Up to 10,000 characters. - `reasons` (string[], optional; one of: `off_brand`, `not_relevant`, `wrong_language`, `needs_work`, `other`): With `rejected`: why. - `comment` (string, optional): With `rejected`: the reason in the person’s own words. Up to 1,000 characters. - `outcome` (object, optional): How the post performed. Send it again whenever the numbers change; the latest one counts. - `channel` (string, required; one of: `instagram`, `facebook`, `linkedin`, `tiktok`, `x`, `threads`, `bluesky`, `pinterest`, `youtube`, `other`): Where the post went out. - `published_at` (string, required): When it went out, in ISO 8601. - `views` (integer, required): Views or impressions. - `likes` (integer, optional): Likes and reactions. - `comments` (integer, optional): Comments. - `shares` (integer, optional): Shares, reposts and sends. - `clicks` (integer, optional): Link clicks. cURL: ```bash curl -X POST https://api.native.no/v1/feedback \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "idea": "idea_4Fd9xW", "decision": "edited", "final_text": "Frozen pipes do not care that it is Sunday. Neither do we: same-day fix, no drama." }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/feedback', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ idea: "idea_4Fd9xW", decision: "edited", final_text: "Frozen pipes do not care that it is Sunday. Neither do we: same-day fix, no drama." }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/feedback", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "idea": "idea_4Fd9xW", "decision": "edited", "final_text": "Frozen pipes do not care that it is Sunday. Neither do we: same-day fix, no drama." }, timeout=30, ) res.raise_for_status() result = res.json() ``` A rejection with a reason: Body: ```json { "idea": "idea_9Qm3vT", "decision": "rejected", "reasons": [ "not_relevant" ], "comment": "We stopped doing leak checks in the winter months." } ``` An outcome, a few days after the post went out: Body: ```json { "idea": "idea_4Fd9xW", "outcome": { "channel": "instagram", "published_at": "2026-11-02T18:00:00-05:00", "views": 4210, "likes": 186, "comments": 23, "shares": 17, "clicks": 9 } } ``` Norn answers `202 Accepted` with a receipt: Response: ```json { "object": "feedback", "id": "fb_2Lr8sN", "idea": "idea_4Fd9xW", "created_at": "2026-11-03T08:00:00Z" } ``` ## What feedback changes - The ideas Norn suggests next for that brand: the subjects, formats and angles it leans toward, and the lessons it follows. - Not the brand’s rules. Only you set `rules`, and Norn never overrides them with what it learns. - Not the order of ideas already sent. Feedback shapes the next request, not the last one. --- # Predictions Source: https://docs.native.no/norn/predictions > **In development.** Not part of the first preview of the Norn API. This page describes the interface as planned. In development: send the drafts you already have, and Norn ranks them before they go out, with how likely each is to beat the brand’s usual post. > **Not in the first preview.** Predictions are still being built. This page shows the interface as we plan it, so you can tell us whether it fits what you are building. Write to [hei@native.no](mailto:hei@native.no). Ideas answer "what should we post?". Predictions answer the question that comes after: "of the posts we have, which one will land?". Send Norn drafts from your team, your own models or your customers, and it ranks them before anything is published. ## What the numbers mean `score` is Norn’s estimate of the chance a draft beats the brand’s usual post on engagement, from 0 to 1. A score of 0.74 means Norn expects the draft to do better than the brand’s typical post about three times in four. `confidence` says how much evidence stands behind the score, from 0 to 1. It starts low for a new brand and rises as outcomes come back through [feedback](https://docs.native.no/norn/feedback). A high score with low confidence is a guess. Treat it like one. ## Acting on a prediction Both numbers are meant to be used in code. A common pattern: publish on your own when both are high, and ask a person when they are not. TypeScript: ```ts const [top] = result.data if (top.score >= 0.7 && top.confidence >= 0.5) { await schedule(top.id) } else { await askForReview(top.id) } ``` ## Create predictions `POST /v1/predictions` (in development) Body: - `brand` (string, required): The id of a brand whose `status` is `ready`. - `candidates` (object[], required): The drafts to rank, from 2 to 20. - `id` (string, required): Your own id for the draft. It comes back with its prediction. - `text` (string, required): The draft’s words. - `format` (string, optional; one of: `image`, `carousel`, `video`, `text`): The format it will go out in. cURL: ```bash curl -X POST https://api.native.no/v1/predictions \ -H "Authorization: Bearer $NATIVE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "brand": "brand_7Hk2pQ", "candidates": [ { "id": "draft-a", "text": "Frozen pipes do not care that it is Sunday. We do. Same-day fix, no drama.", "format": "video" }, { "id": "draft-b", "text": "Winter is here. Book your annual plumbing inspection today.", "format": "image" } ] }' ``` TypeScript: ```ts const res = await fetch('https://api.native.no/v1/predictions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NATIVE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ brand: "brand_7Hk2pQ", candidates: [ { id: "draft-a", text: "Frozen pipes do not care that it is Sunday. We do. Same-day fix, no drama.", format: "video" }, { id: "draft-b", text: "Winter is here. Book your annual plumbing inspection today.", format: "image" } ] }), }) if (!res.ok) throw new Error(`Norn answered ${res.status}: ${await res.text()}`) const result = await res.json() ``` Python: ```python import os import requests res = requests.post( "https://api.native.no/v1/predictions", headers={"Authorization": f"Bearer {os.environ['NATIVE_ACCESS_TOKEN']}"}, json={ "brand": "brand_7Hk2pQ", "candidates": [ { "id": "draft-a", "text": "Frozen pipes do not care that it is Sunday. We do. Same-day fix, no drama.", "format": "video" }, { "id": "draft-b", "text": "Winter is here. Book your annual plumbing inspection today.", "format": "image" } ] }, timeout=30, ) res.raise_for_status() result = res.json() ``` Response: ```json { "object": "list", "model": "norn-preview", "brand": "brand_7Hk2pQ", "data": [ { "object": "prediction", "id": "draft-a", "rank": 1, "score": 0.74, "confidence": 0.41 }, { "object": "prediction", "id": "draft-b", "rank": 2, "score": 0.22, "confidence": 0.41 } ] } ``` ## What it will not do at first - It reads words only. To rank a picture or a video, describe it in `text`. - It ranks drafts within one brand. It does not compare brands with each other. - A score is for ranking and for thresholds. It is not a forecast of reach, clicks or sales. --- # API reference Source: https://docs.native.no/norn/reference > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. The base URL, authentication, request format, errors, limits and versions of the Norn API, and every endpoint on one list. > **Planned values.** The address and the limits on this page are what we plan for the preview. The email that opens your access confirms them. ## Base URL Base URL: ```text https://api.native.no/v1 ``` Every request goes over HTTPS. Plain HTTP is refused. ## Authentication Send an access token as a bearer token in the `Authorization` header of every request. You get it with your client ID and secret and the Norn API as the `audience`, as [Authentication](https://docs.native.no/authentication) shows: HTTP: ```http POST /v1/ideas HTTP/1.1 Host: api.native.no Authorization: Bearer Content-Type: application/json ``` Your credentials belong to your team and reach every brand your team creates, and no others. A token lasts at most an hour: cache it and fetch a new one before it runs out. ## Requests and responses - Request bodies are JSON, sent with `Content-Type: application/json`. Responses are JSON too. - Field names are `snake_case`, and times are ISO 8601. - Ids start with the kind of thing they name: `brand_`, `batch_`, `idea_`, `fb_`. - Every object has an `object` field that names its kind. Lists come back as an object with `"object": "list"` and the items in `data`. - New fields can appear at any time. Write code that ignores the ones it does not use. ## Errors An error comes back with an HTTP status and a body that says what went wrong. When it is about one field, `param` names it: 409 Conflict: ```json { "error": { "type": "invalid_request", "code": "brand_not_ready", "message": "Norn is still reading https://pipedream.example. Ask again when the brand’s status is ready.", "param": "brand" } } ``` | Status | `type` | When | | --- | --- | --- | | `400` | `invalid_request` | A field is missing, or has the wrong type or value. | | `401` | `authentication_error` | The access token is missing, expired or not for this API. | | `403` | `permission_error` | Your credentials cannot reach that brand. | | `404` | `not_found` | There is no brand, batch or idea with that id. | | `409` | `invalid_request` | The brand is not ready yet. `code` is `brand_not_ready`. | | `429` | `rate_limit` | Too many requests. Wait as many seconds as the `Retry-After` header says. | | `500` | `server_error` | Something broke on our side. Try once more, then tell us. | ## Limits - Your client ID has a request limit during the preview, agreed with you when access opens. Past it, the API answers `429`. - Every call answers within seconds. The slow work, reading a website and thinking up ideas, runs in the background: you get a brand or a batch back at once and ask for it again until it is ready. Ask no more often than every 10 seconds. - Up to 10 ideas per request, and up to 20 drafts per prediction. ## Versions The version is part of the path: `/v1`. A change that would break your code gets a new version. The `model` field in each response names the model that answered, so you can tell when Norn itself has changed. ## All endpoints | Endpoint | What it does | Status | | --- | --- | --- | | `POST /v1/brands` | [Create a brand](https://docs.native.no/norn/brands#create) | Preview | | `GET /v1/brands/{brand_id}` | [Get a brand](https://docs.native.no/norn/brands#get) | Preview | | `PATCH /v1/brands/{brand_id}` | [Update a brand](https://docs.native.no/norn/brands#update) | Preview | | `POST /v1/ideas` | [Create ideas](https://docs.native.no/norn/ideas#create-ideas) | Preview | | `GET /v1/ideas/{batch_id}` | [Get a batch](https://docs.native.no/norn/ideas#get-a-batch) | Preview | | `POST /v1/feedback` | [Send feedback](https://docs.native.no/norn/feedback#send-feedback) | Preview | | `POST /v1/predictions` | [Create predictions](https://docs.native.no/norn/predictions#create-predictions) | In development | --- # Patterns Source: https://docs.native.no/norn/patterns > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. Ways to build with Norn: an idea feed in your product, a first week of posts for every new customer, a person in the loop, and ideas for an agent. Norn is a building block. These are the shapes we expect it to fit first. Each one uses only the calls in these docs. ## An idea feed in your product For a content tool, a social media manager or a CMS: show each customer a short list of ideas for their brand, ready to use or skip. 1. Create a brand for each customer when they sign up, from the website they gave you. 2. Ask for three to five ideas when they open the planner, or once each morning. 3. Show `title`, `hook` and `why_now`, and keep `sources` one click away so people can check where an idea came from. 4. Send every decision back: used, changed or skipped. Ask for a reason now and then, not on every skip. Native does the same, so reasons keep coming without nagging. ## A first week of posts for every new customer For a website builder, a booking system or a CRM: the moment a business signs up, you know its website. Turn that into its first week of posts. TypeScript: ```ts const brand = await norn('POST', '/brands', { website: signup.website, country: signup.country, }) await waitUntilReady(brand.id) const batch = await norn('POST', '/ideas', { brand: brand.id, count: 5 }) const done = await waitUntilDone(batch.id) for (const idea of done.data) { await drafts.create({ customerId: signup.id, title: idea.title, prompt: idea.prompt }) } ``` `norn` is a small helper around `fetch` that adds your access token, as in the [Quickstart](https://docs.native.no/norn/quickstart). `waitUntilReady` asks for the brand every ten seconds until its `status` is `ready`, and `waitUntilDone` asks for the batch every 15 seconds until it is `done`. Both throw when the status is `failed`, so a failure ends the job instead of waiting forever. Run this in a background job: a brand and a batch together take minutes, not milliseconds. ## A person in the loop For agencies and teams that approve everything before it goes out: let Norn propose and let people decide. Ask for more ideas than you need, show the top few to whoever approves, and send every decision back. Approvals tell Norn what good looks like for this client. Reasons tell it why. ## Ideas for an AI agent If you build an agent that writes and posts on its own, have it call Norn first and write from the `prompt`. The agent keeps doing what language models are good at, the writing, and Norn decides what is worth writing about. ## Later: checking drafts before they go out When [predictions](https://docs.native.no/norn/predictions) arrive, the loop can also run the other way: your team or your models write, and Norn ranks the drafts before anything is published. --- # FAQ Source: https://docs.native.no/norn/faq > **Preview.** The Norn API is not live yet. This page describes the planned interface; names and fields can change before access opens. Common questions about the Norn API: what Norn is, how it differs from a chat model, what is live, and what it will cost. ## Is the API live? Not yet. Access opens from the [waitlist](https://native.no/meet-norn?from=docs#waitlist). Join it and we will email you when your access is ready. Norn itself is at work inside Native: the ideas Native suggests to its customers come through it. ## What is Norn? Norn is Native’s model for one decision: what to post. It does not write the post. It weighs a brand’s material, its history and its feedback, and returns ideas in order, best first. The name comes from the norns of Norse myth, who weave fate at the roots of the world tree. [Meet Norn](https://native.no/meet-norn) tells the story. ## How is this different from asking a chat model for ideas? A chat model gives you the most likely ideas, and those tend to be the ones everyone else gets too. Norn ranks ideas by how well they should work for one brand, using what that brand’s people kept and turned down and how its posts performed. Norn’s ideas also cite their sources, so a claim about the business can be checked before it is posted. ## Does Norn write the posts? No. Each idea comes with a `prompt` you can give to any generative model, and an `outline` if you would rather write it yourself. Inside Native, Native writes and designs the post from the same idea. ## Does Norn learn from my feedback? Yes, for the brand the feedback is about. Decisions shape what Norn suggests next for that brand, and reasons become lessons it follows. See [Feedback](https://docs.native.no/norn/feedback). ## What happens to the data I send? The terms that come with your access set out how what you send is stored and used. If you need the details before you join, write to [hei@native.no](mailto:hei@native.no). ## Which languages does it work in? Ideas are written in the brand’s language, whichever it is. Norn does not check that an idea came back in that language yet, so tell it with the reason `wrong_language` when one did not. ## Which channels are the ideas for? Ideas are not tied to a channel. Norn suggests a format, such as a carousel or a short video, and you decide where the post goes. One idea can become a post on several channels. ## What will it cost? Pricing is not set yet. You will see the price before you are charged anything. ## Is there an SDK? Not yet. The API is JSON over HTTPS, and every example comes in cURL, TypeScript and Python. If you build with a coding agent, give it [llms-full.txt](https://docs.native.no/llms-full.txt), which holds these docs as plain text. ## Who is it for? Products that help businesses market themselves: content tools, website builders, booking systems, CRMs, and agencies with many clients. If your users ask "what should I post?", Norn is meant to answer. Something not answered here? Write to [hei@native.no](mailto:hei@native.no). --- # Native API Source: https://docs.native.no/native-api > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. MCP tools that let a partner run Native for its own clients from its backend: onboard, generate, approve and publish. Not live for partners yet. > **Not live for partners yet.** Partners cannot call the Native API yet. Partner sign-in with client credentials is still in development, and once it ships it stays off until Native switches it on for you. Tools marked Preview run in Native’s MCP gateway today, but only for people signed in to Native. Everything marked In development is not deployed yet, and its names and fields can still change. Rather have your coding agent set it up? Give it the one line on [Set up with an agent](https://docs.native.no/native-api/agent-setup), and it adds the Native API to your project, ready for when your access opens. ## What the Native API is for The Native API is for partners who run Native for their own clients, from their own backend. Your code onboards a client, generates posts, collects the client’s approval in your own interface, publishes, and handles what fails. No language model sits in between: your backend calls the tools directly. Each client is a Native workspace of its own, with one brand. Every call names the client by its `brandId`, or by the id of a post that belongs to it. The tools also call a brand a profile: `create_profile` makes one and `get_profile` reads it. Work for one client runs in this order: 1. **Onboard the client** from its website, and wait while Native reads it. See [Profiles](https://docs.native.no/native-api/profiles). 2. **Set its look**: colours, fonts and logo. See [Branding](https://docs.native.no/native-api/branding). 3. **Connect its social accounts.** The client logs in with each platform. See [Social accounts](https://docs.native.no/native-api/social-accounts). 4. **Generate posts** from a brief, then edit them. See [Generating posts](https://docs.native.no/native-api/generating-posts) and [Editing posts](https://docs.native.no/native-api/editing-posts). 5. **Collect the client’s approval** of the exact content that will go out. See [Approval](https://docs.native.no/native-api/approval). 6. **Schedule or publish**, then track each destination. See [Publishing](https://docs.native.no/native-api/publishing). 7. **Offboard the client** when the service ends, or erase it on request. See [Profiles](https://docs.native.no/native-api/profiles). [The flow](https://docs.native.no/native-api/flow) walks through it call by call. ## How it is shaped The Native API is a set of [MCP](https://modelcontextprotocol.io) tools, not REST endpoints. Your backend calls them over MCP’s Streamable HTTP transport at `https://mcp.native.no/mcp`. - Every call is an MCP `tools/call`. Use the official [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk) rather than writing JSON-RPC by hand. - The gateway is stateless. Each request stands on its own, and nothing is kept between calls. - `tools/list` returns every tool with its `inputSchema` and `outputSchema` as JSON Schema. Check these pages against it. When the two disagree, the live list is right. - A few tools declare no fields in their output schema, only that the payload is an object. Their pages list what comes back. - The output schema of a tool that changes data also allows `{ "cancelled": true, "changed": false }`. That is the answer when a person declines a confirmation in an assistant. Your backend’s calls are never asked for one, so you do not get it. - Tools that change data need the `mcp:write` scope as well as `mcp:read`. Each tool’s `readOnlyHint` annotation says which kind it is. - Generation runs in the background. `create_posts` answers with a `batchId` and `regenerate_post_image` with a `jobId`, and you poll until the work is done. There are no webhooks yet. - Three tools are for partners only: `create_profile`, `deactivate_profile` and `delete_profile`. They appear in `tools/list` only for a partner token. ## What the badges mean | Badge | Meaning | | --- | --- | | Preview | The tool is in Native’s MCP gateway today and serves people signed in to Native. A partner can call it once partner sign-in is switched on for them. | | In development | The tool, field or error code is built but not deployed. These pages show its planned shape, which can change before it ships. | A Preview tool can have fields that are still in development. The description of each such field starts with “New, in development”. Until partner sign-in ships, both badges mean the same thing for a partner: you can plan against the tool, but you cannot call it. ## The tools | Tool | What it does | Status | | --- | --- | --- | | [`create_profile`](https://docs.native.no/native-api/profiles) | Creates a client’s workspace and brand from its website. | In development | | [`get_profile`](https://docs.native.no/native-api/profiles) | Shows a brand’s channels, their health, and whether it can generate and publish. | Preview | | [`get_workspaces`](https://docs.native.no/native-api/profiles) | Lists the workspaces the token can reach. | Preview | | [`list_brands`](https://docs.native.no/native-api/profiles) | Lists the brands in one workspace. | Preview | | [`deactivate_profile`](https://docs.native.no/native-api/profiles) | Ends service for a client. Nothing is deleted. | In development | | [`delete_profile`](https://docs.native.no/native-api/profiles) | Erases a client for good, for example on a GDPR request. | In development | | [`get_brand_style`](https://docs.native.no/native-api/branding) | Reads a brand’s colours, fonts and logos. | In development | | [`update_brand_style`](https://docs.native.no/native-api/branding) | Adds colours and sets colour roles and fonts. | In development | | [`upload_brand_logo`](https://docs.native.no/native-api/branding) | Saves a logo and, by default, makes it the one Native uses. | In development | | [`create_connect_link`](https://docs.native.no/native-api/social-accounts) | Makes a single-use link the client opens to connect a social account. | Preview | | [`get_connect_options`](https://docs.native.no/native-api/social-accounts) | Lists the Pages, organisations, boards or locations a connect can bind to. | In development | | [`complete_connect`](https://docs.native.no/native-api/social-accounts) | Finishes a connect with the option the client chose. | In development | | [`create_posts`](https://docs.native.no/native-api/generating-posts) | Generates 1 to 20 draft posts from one brief, in the background. | Preview | | [`get_post_batch_status`](https://docs.native.no/native-api/generating-posts) | Reads how a batch is going, and the posts it made. | Preview | | [`view_post`](https://docs.native.no/native-api/generating-posts) | Reads one post exactly as it would publish. | Preview | | [`update_post`](https://docs.native.no/native-api/editing-posts) | Changes the copy, first comment, time or destinations of a post. | Preview | | [`generate_caption`](https://docs.native.no/native-api/editing-posts) | Rewrites a caption in the brand’s voice. | Preview | | [`regenerate_post_image`](https://docs.native.no/native-api/editing-posts) | Renders one picture of a post again, in the background. | Preview | | [`get_generation_job`](https://docs.native.no/native-api/editing-posts) | Reports how a picture render ended. | Preview | | [`approve_post`](https://docs.native.no/native-api/approval) | Records approval of a post’s exact content. | Preview | | [`reject_post`](https://docs.native.no/native-api/approval) | Records a rejection with a reason. | Preview | | [`schedule_post`](https://docs.native.no/native-api/publishing) | Books a post, or some of its destinations, for a time. | Preview | | [`reschedule_posts`](https://docs.native.no/native-api/publishing) | Books or moves up to 25 posts in one call. | Preview | | [`publish_post_now`](https://docs.native.no/native-api/publishing) | Publishes now, or retries the destinations that failed. | Preview | | [`unschedule_platform`](https://docs.native.no/native-api/publishing) | Withdraws one destination from its booking. | Preview | | [`get_post_status`](https://docs.native.no/native-api/publishing) | Reports the state of each destination of a post. | Preview | The gateway has other tools, such as `create_draft`, `delete_post`, `generate_image` and `upload_image`. These pages do not cover them yet, and `tools/list` has their schemas. ## What is still in development Partners cannot call the API until the first item below ships. The rest follow it. Native has not set dates for any of them, and will tell partners on the waiting list as each one goes live. | Feature | What it gives you | | --- | --- | | Partner sign-in | Client credentials for your backend, and the codes `partner_only` and `partner_not_found`. | | Approval digests | `contentDigest` and `approval` on post status, drafts and batch items, and `postId` and `appUrl` on review decisions. | | Dry runs | `dryRun` on `schedule_post` and `publish_post_now`. | | A versioned endpoint | `/mcp/v1`, which keeps its contract while newer versions ship. | | Connecting accounts from your own app | `platform` and `returnUrl` on `create_connect_link`, plus `get_connect_options` and `complete_connect`. | | Brand settings | `get_brand_style`, `update_brand_style` and `upload_brand_logo`. | | Ending a client | `deactivate_profile` and `delete_profile`. | | Approved content stays as approved | The codes `approval_required` and `design_changed_after_approval`. | | Text on pictures | `slideText` on each picture, and rendering from design templates. | | Alt text | `altText` on each picture. | | Creating clients | `create_profile`, `onboarding` on `get_profile`, and the code `profile_not_ready`. | ## Calling conventions ### Calling a tool Every call is an MCP `tools/call` over Streamable HTTP, with a bearer token in the `Authorization` header. [Authentication](https://docs.native.no/authentication) shows how to get the token. With the token in hand, a call looks like this: Calling get_profile: ```ts import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // token comes from accessToken() on /docs/authentication, brandId from create_profile. const client = new Client({ name: 'partner-backend', version: '1.0.0' }) await client.connect( new StreamableHTTPClientTransport(new URL('https://mcp.native.no/mcp'), { requestInit: { headers: { Authorization: `Bearer ${token}` } }, }), ) const result = await client.callTool( { name: 'get_profile', arguments: { brandId } }, undefined, { timeout: 150_000 }, // some writes take up to about 120 s ) const data = (result.structuredContent as { data: any } | undefined)?.data if (result.isError) { // data is the error envelope: { ok: false, error: { code, message, retryable } }. // A call that breaks the input schema has no envelope, only text in result.content. console.error(data?.error?.code ?? result.content, data?.error?.retryable) } else { // data is the tool's payload console.log(data.readiness) } ``` The token is set when the transport is created, so make a new client when you fetch a new token. Raise the SDK’s request timeout as shown. Otherwise a slow write can time out on your side after it has already succeeded on Native’s. [Limits](https://docs.native.no/native-api/limits) has the numbers. ### Success On success, `result.structuredContent` is always `{ "data": }`, where the payload is the object the tool’s page describes. `result.content[0].text` holds the same payload as a JSON string, and `isError` is absent. structuredContent from get_profile, shortened: ```json { "data": { "brandId": "3f6b1c2e-8d4a-4f7e-9b1a-2c5d8e0f4a71", "brandName": "Harbour Bakery", "isActive": true, "readiness": { "canGenerate": true, "canPublish": false, "blockers": [ "no_channel_connected" ] } } } ``` ### Failure When a tool fails, the result has `isError: true`, and `structuredContent` is `{ "data": }`. Here `schedule_post` found a caption too long for Instagram before it contacted any platform: structuredContent from schedule_post, message shortened: ```json { "data": { "ok": false, "error": { "code": "schedule_preflight_failed", "message": "Nothing was scheduled: at least one destination will not publish this post as it stands.", "retryable": false, "blockedDestinations": [ { "platform": "INSTAGRAM", "code": "caption_too_long", "characterCount": 2410, "characterLimit": 2200 } ] } } } ``` The error envelope: - `ok` (boolean, required): Always `false`. - `error` (object, required): What went wrong. - `code` (string, required): What failed, as a stable code. Branch on this. - `message` (string, required): A sentence Native writes for people. It can change at any time, so show it or log it, but never parse it. - `retryable` (boolean, required): Whether the same call, unchanged, could succeed. `false` means something has to change first. - Some codes add fields of their own next to `code`, such as `invalidFields`, `blockedDestinations`, `connectedPlatforms`, `resetDate` or `jobId`. [Errors](https://docs.native.no/native-api/errors) lists each one with the code that uses it. - A code the gateway does not recognise arrives as `upstream_`, with the HTTP status Native’s backend answered, for example `upstream_409`. A 4xx is not retryable. A 429 or a 5xx is. - An argument that breaks a tool’s input schema is refused by the MCP SDK before the tool runs. The result has `isError: true` and the SDK’s validation message as text, with no envelope. This includes an argument the tool does not take: every tool on these pages refuses unknown arguments. Treat it as a bug in your call. - A missing, expired or invalid token is refused at the HTTP level, before any tool runs, so it never gets this envelope. See [Authentication](https://docs.native.no/authentication). ### Ids `brandId`, post ids, `batchId`, `jobId` and `platformPostId` are opaque strings. Store them as you get them, and do not build or parse them. A few tools, `create_connect_link` among them, also refuse a `brandId` that is not a UUID. Tools that act on a post take its id as `id` (for example `view_post`, `approve_post` and `schedule_post`) or as `postId` (for example `generate_caption` and `regenerate_post_image`). Each tool’s page says which. `view_post` and `get_post_status` return the id as both `id` and `postId`, with the same value. ### Destination names A post’s destinations are uppercase names from Native’s platform list, such as `INSTAGRAM`, `FACEBOOK` or `INSTAGRAM_STORIES`. You see them in `platforms`, `destinations[].platform` and `platformResults[].platform`. `get_profile` is the exception. Its `channels[].platform` and `storyDestinations` use lowercase channel names, such as `instagram` and `instagram_stories`. Stories use the base Instagram or Facebook connection, so they never appear as channels of their own. The `platform` input of `create_connect_link`, which is in development, takes lowercase names too. ### Times Send times as ISO 8601 with `Z` or a numeric offset, for example `2026-11-02T09:00:00+01:00`. The gateway converts them to UTC. ### Retrying writes The gateway attaches an idempotency key to every write it makes to Native, but each tool call gets a fresh one. If you send the same call again, it is a new attempt and is not deduplicated: a repeated `create_posts` makes a second batch. Idempotency keys of your own are planned, not available. So after a timeout or a dropped connection on a write, read the current state before you try again, with `get_post_status`, `get_post_batch_status` or `view_post`. A lost response does not prove that nothing changed. [Errors](https://docs.native.no/native-api/errors) has the retry rules. ### Fields and codes you do not know Results can carry more fields than these pages list. New output fields, error codes and enum values can also appear at any time. Ignore the fields you do not use, and handle a code you do not know by its `retryable` flag. ## Versions `https://mcp.native.no/mcp` always serves the latest version of the tools. A pinned endpoint, `https://mcp.native.no/mcp/v1`, is in development. Once it ships, partners should call it instead. The same token works on both, so switching versions changes only the URL. With versioning, the `initialize` result reports the version you are on, as `capabilities.experimental["no.native/partner-api"]` with `version` and `latestVersion`. When `latestVersion` is greater than `version`, a newer contract has shipped. The policy that comes with it: Native announces a breaking change at least two weeks before it ships, and the previous version keeps working for at least four weeks after. The announcement gives the date the old version shuts down. After that date, its path answers HTTP 404. Breaking changes are: removing or renaming a tool or a field, tightening an input, changing what a field or code means, removing an enum value from output, changing which error code a failure returns, and changing a default. New tools, new optional inputs, new output fields, new enum values in output and new codes for new failures are not breaking, and can ship at any time. ## What is not available yet - **Partner sign-in.** Machine-to-machine access is in development. Until it ships, a partner can call no tool at all. - **Webhooks.** They are planned for finished generation, approval changes, publish results and disconnected accounts. Until then, poll. - **Idempotency keys of your own.** Resending a write is a new attempt, so read the current state before you retry. - **Usage per client.** A monthly usage report for each client is planned. ## Next steps - [Flow](https://docs.native.no/native-api/flow): The calls for one client, from onboarding to a published post. - [Profiles](https://docs.native.no/native-api/profiles): Create a client from its website, check what it can do, and offboard it. - [Branding](https://docs.native.no/native-api/branding): Read and set a brand’s colours, fonts and logo. - [Social accounts](https://docs.native.no/native-api/social-accounts): Let a client connect Instagram, Facebook and its other accounts. - [Generating posts](https://docs.native.no/native-api/generating-posts): Turn a brief into draft posts, with copy for each destination. - [Editing posts](https://docs.native.no/native-api/editing-posts): Change copy, destinations and pictures before the client reviews them. - [Approval](https://docs.native.no/native-api/approval): Record the client’s decision on the exact content that will go out. - [Publishing](https://docs.native.no/native-api/publishing): Schedule, publish now, and track each destination. - [Errors](https://docs.native.no/native-api/errors): Every error code, what it means, and whether to retry. - [Limits](https://docs.native.no/native-api/limits): Batch sizes, timeouts, upload sizes and the plan’s allowance. --- # Set up with an agent Source: https://docs.native.no/native-api/agent-setup > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Give your coding agent one line, and it adds the Native API to your project: the credentials, a token helper, an MCP client and a check you can run. > **Not live for partners yet.** Partners cannot call the Native API yet. Partner sign-in with client credentials is still in development, and once it ships it stays off until Native switches it on for you. Your agent can write the code today, so it is ready when your access opens. The line to give your coding agent: ```text Set up the Native API in this project by following https://docs.native.no/native-api/agent-setup.md ``` Works in any coding agent that can read a web page. If yours cannot, use Copy page on the setup page and paste that in. Or start your agent with it, from your project folder: Claude Code: Claude Code asks before it reads docs.native.no. ```bash claude 'Set up the Native API in this project by following https://docs.native.no/native-api/agent-setup.md' ``` Codex: By default Codex cannot fetch a new page. Approve its network request when it asks, start it with `codex --search`, or use Copy page on the setup page and paste that in. ```bash codex 'Set up the Native API in this project by following https://docs.native.no/native-api/agent-setup.md' ``` Cursor CLI: Cursor asks before it reads docs.native.no. ```bash agent 'Set up the Native API in this project by following https://docs.native.no/native-api/agent-setup.md' ``` Copilot CLI: Copilot asks before it reads docs.native.no. ```bash copilot -i 'Set up the Native API in this project by following https://docs.native.no/native-api/agent-setup.md' ``` Gemini CLI: Gemini CLI asks before it reads docs.native.no. ```bash gemini -i 'Set up the Native API in this project by following https://docs.native.no/native-api/agent-setup.md' ``` [Open in Cursor](https://cursor.com/link/prompt?text=Set%20up%20the%20Native%20API%20in%20this%20project%20by%20following%20https%3A%2F%2Fdocs.native.no%2Fnative-api%2Fagent-setup.md): opens Cursor’s chat with the line filled in, without sending it. - It adds `NATIVE_CLIENT_ID` and `NATIVE_CLIENT_SECRET` to your example env file, empty. You fill them in when Native issues your credentials. - It adds a token helper, a small client for the tools and a check you can run. - It never writes a secret into a file git tracks, and never puts one in browser or mobile code. - It does not call the Native API, since partner access is not open yet. - It adds nothing that schedules, publishes, deactivates or deletes, unless you ask for it. - It asks before it installs a package, and tells you what it changed. ## Instructions for your agent If the person asked you to set up the Native API, follow these steps in order, and keep to these rules throughout: - The Native API is not live for partners yet. Write the code, but make no call to `https://auth.native.no` or `https://mcp.native.no` until the person’s access opens. - Use only what these docs state: the tool names, fields, scopes and addresses on these pages. When you need something they do not cover, leave a `TODO(native-api)` comment saying what is missing, tell the person, and do not guess. - For more detail, read [llms-full.txt](https://docs.native.no/llms-full.txt), every page in one file. [llms.txt](https://docs.native.no/llms.txt) lists the pages, each with a Markdown copy. - Ask the person before you install a package the project does not already have. ## 1. Look at the project first Before you write anything, find out the language and framework, the package manager, where server-side code lives, where environment config lives, and which HTTP client and MCP SDK the project already uses. Write the code in the project’s own language, style and folders. If it already has Native code, for example from the Norn API setup, build on that rather than adding a second copy. The Native API is MCP tools, not REST endpoints. Call them with the official MCP SDK: `@modelcontextprotocol/sdk` in TypeScript or JavaScript, `mcp` in Python. These docs show MCP calls in TypeScript, so check the Python names below against the version the project installs. For another language, use its official MCP SDK, or leave a TODO if it has none. If the project has no server side, such as a browser app, a mobile app or a static site, stop here and tell the person. The client secret must never reach a browser or a mobile app, so the calls need a backend to run from. In a repository with several apps, use the one with a server side, and say which you chose. ## 2. Add the credentials The Native API and the Norn API share one login: a client ID and secret that Native issues when access opens. Use exactly these names: .env.example: ```bash # Native login, shared by the Native API and the Norn API (https://docs.native.no/authentication). # Native issues these when your access opens. Never commit real values. NATIVE_CLIENT_ID= NATIVE_CLIENT_SECRET= ``` - Add both names, with empty values, to the project’s example env file. Create `.env.example` if there is none. - Write the empty names into a local env file too, such as `.env` or `.env.local`, but only into one that git ignores and does not track. Check both: `git check-ignore ` prints the file, and `git ls-files ` prints nothing. Never write a real value into a file git tracks. - If the local env file is tracked, do not write there, and do not add it to `.gitignore`: that does not stop git tracking it. Use an ignored file such as `.env.local` instead, and name it in your summary. - If the project checks its environment in code, add both as optional and server-only, so the app still starts while they are empty. - If the names are already there, leave them as they are. ## 3. Add a token helper Follow [Authentication](https://docs.native.no/authentication#get-a-token): the client-credentials grant at `https://auth.native.no/oauth/token`, with `audience` set to `https://mcp.native.no/mcp` and `scope` set to `mcp:read mcp:write`. The audience stays `https://mcp.native.no/mcp` whichever endpoint the client calls, the pinned `/mcp/v1` included. A token lasts at most an hour. Cache one token per audience, and fetch a new one a minute before `expires_in` runs out. Calls that arrive while a token is on its way share that request, so a busy backend fetches one token, not one per call. Put it in a server-only module. If the project already has this helper, reuse it. Use the HTTP client the project has: the samples use `fetch` and `requests`, as Authentication does. Keep the token endpoint, the audience and the MCP address as separate named constants, never repeated inline: [sandbox credentials](https://docs.native.no/authentication#sandbox) come with addresses of their own. TypeScript: ```ts const TOKEN_URL = 'https://auth.native.no/oauth/token' // Cache one token per API and fetch a new one a minute before it runs out. // Calls that arrive while a token is on its way share that request. const cache = new Map() const pending = new Map>() export async function accessToken(audience: string, scope: string): Promise { const key = `${audience} ${scope}` const hit = cache.get(key) if (hit && hit.expiresAt - Date.now() > 60_000) return hit.token let request = pending.get(key) if (!request) { request = requestToken(audience, scope) .then((entry) => { cache.set(key, entry) return entry.token }) .finally(() => pending.delete(key)) pending.set(key, request) } return request } async function requestToken(audience: string, scope: string) { const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.NATIVE_CLIENT_ID, client_secret: process.env.NATIVE_CLIENT_SECRET, audience, scope, }), }) if (!res.ok) throw new Error(`Token request failed: ${res.status}`) const body = (await res.json()) as { access_token: string; expires_in: number } return { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 } } ``` Python: ```python import os import time import requests TOKEN_URL = "https://auth.native.no/oauth/token" # Cache one token per API and fetch a new one a minute before it runs out. _cache: dict[str, tuple[str, float]] = {} def access_token(audience: str, scope: str) -> str: key = f"{audience} {scope}" hit = _cache.get(key) if hit and hit[1] - time.time() > 60: return hit[0] res = requests.post( TOKEN_URL, data={ "grant_type": "client_credentials", "client_id": os.environ["NATIVE_CLIENT_ID"], "client_secret": os.environ["NATIVE_CLIENT_SECRET"], "audience": audience, "scope": scope, }, timeout=30, ) res.raise_for_status() body = res.json() _cache[key] = (body["access_token"], time.time() + body["expires_in"]) return body["access_token"] ``` ## 4. Add a client for the tools Put every Native API call in one server-only module, as [the flow](https://docs.native.no/native-api/flow#connect-to-the-gateway) does: - One MCP client per token, over Streamable HTTP at `https://mcp.native.no/mcp`, with the token as a bearer token on the transport. Make a new client when the token changes, and keep it for every call until then rather than connecting for each call. Cache the client while it connects, so calls that arrive together share one, and drop it if connecting fails. - A request timeout of 150 seconds on every call. The gateway waits up to 120 seconds for Native, and the SDK’s default is shorter. - On `isError`, read the envelope in `structuredContent.data.error` and raise an error that carries `code` and `retryable`. Branch on `code`, never on `message`. A result with no envelope means the SDK refused the arguments: fix the call. See [Errors](https://docs.native.no/native-api/errors). - Never resend a write after a timeout. A repeated call is a new attempt, so read the state first with `get_post_status`, `get_post_batch_status` or `view_post`. - Run the waiting in a background job, never inside a web request. `create_posts` answers at once with a `batchId`: store it, then poll from the job. A web request can time out after the batch has started, and a second `create_posts` makes a second batch. Then add typed wrappers, with the fields on each tool’s page, for the tools that onboard a client and get its posts approved: `create_profile` and `get_profile` ([Profiles](https://docs.native.no/native-api/profiles)), `create_connect_link` ([Social accounts](https://docs.native.no/native-api/social-accounts)), `create_posts` with `get_post_batch_status` ([Generating posts](https://docs.native.no/native-api/generating-posts)), `view_post`, `approve_post` and `reject_post` ([Approval](https://docs.native.no/native-api/approval)), and `get_post_status` ([Publishing](https://docs.native.no/native-api/publishing)). Wrap `create_posts` in two functions, as the samples do: one starts the batch and returns its `batchId`, and one waits for a `batchId`. Poll a batch as the flow does, and give up after 15 minutes. Add no wrapper for `schedule_post`, `reschedule_posts`, `publish_post_now`, `unschedule_platform`, `deactivate_profile` or `delete_profile` unless the person asks for it. These put posts in front of the public, or end or erase a client. TypeScript: ```ts import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { accessToken } from './native-token' const MCP_URL = 'https://mcp.native.no/mcp' // The token's audience stays the same whichever endpoint MCP_URL names. const NATIVE_API_AUDIENCE = 'https://mcp.native.no/mcp' let cached: { token: string; client: Promise } | undefined // One client per token: the token is set on the transport, so a new token needs a new client. // The client is cached while it connects, so calls that arrive together share it. async function nativeClient(): Promise { const token = await accessToken(NATIVE_API_AUDIENCE, 'mcp:read mcp:write') if (cached?.token === token) return cached.client const client = connect(token) cached = { token, client } // A connection that fails is dropped, so the next call tries again. client.catch(() => { if (cached?.client === client) cached = undefined }) return client } async function connect(token: string): Promise { const client = new Client({ name: 'partner-backend', version: '1.0.0' }) await client.connect( new StreamableHTTPClientTransport(new URL(MCP_URL), { requestInit: { headers: { Authorization: `Bearer ${token}` } }, }), ) return client } export async function closeNative(): Promise { const connecting = cached?.client cached = undefined // A client that never connected has nothing to close. const client = await connecting?.catch(() => undefined) await client?.close() } export class NativeError extends Error { code: string retryable: boolean body: object constructor(code: string, retryable: boolean, body: object) { super(code) this.code = code this.retryable = retryable this.body = body } } // Every call goes through here, with a timeout above the gateway's 120 seconds. export async function callNative(name: string, args: Record): Promise { const client = await nativeClient() const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 150_000 }) const data = (result.structuredContent as { data?: any } | undefined)?.data if (!result.isError) return data as T // No envelope: the SDK refused the arguments before the tool ran. Fix the call. if (!data?.error) throw new Error(`${name}: arguments refused by the input schema`) throw new NativeError(data.error.code, data.error.retryable, data.error) } // Wrappers, with the fields on each tool's page. Add the others the same way. type BuildStatus = 'building' | 'ready' | 'failed' export interface Profile { brandId: string brandName: string companyId: string isActive: boolean readiness: { canGenerate: boolean; canPublish: boolean; blockers: string[] } onboarding?: { status: BuildStatus; error?: string } // in development } export const getProfile = (brandId: string) => callNative('get_profile', { brandId }) export const createProfile = (input: { websiteUrl: string; name: string; externalId: string }) => callNative<{ brandId: string; companyId: string; status: BuildStatus; created: boolean }>( 'create_profile', input, ) interface PostBatch { settled: boolean imagesPendingCount?: number items: { status: string; postId: string | null }[] } // Starts a batch from one brief and answers at once. Store the batchId before you wait. export async function createPosts(brandId: string, brief: string, count: number): Promise { const { batchId } = await callNative<{ batchId: string }>('create_posts', { brandId, brief, count }) return batchId } // Waits for a batch, polling as the flow page does, and returns the created post ids. // Run it in a background job, never inside a web request. export async function waitForBatch(batchId: string): Promise { const started = Date.now() let delay = 5_000 let batch = await callNative('get_post_batch_status', { batchId }) while (!batch.settled || (batch.imagesPendingCount ?? 0) > 0) { const elapsed = Date.now() - started if (elapsed > 15 * 60_000) throw new Error(`Batch ${batchId} has not finished after 15 minutes`) // 5 seconds for the first minute, then 15, growing to no more than 30. if (elapsed > 60_000) delay = Math.min(30_000, Math.max(15_000, delay * 1.5)) await new Promise((resolve) => setTimeout(resolve, delay)) batch = await callNative('get_post_batch_status', { batchId }) } return batch.items.flatMap((item) => (item.status === 'created' && item.postId ? [item.postId] : [])) } ``` Python: ```python import asyncio import time from contextlib import AsyncExitStack from datetime import timedelta from typing import Any from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client from native_token import access_token MCP_URL = "https://mcp.native.no/mcp" # The token's audience stays the same whichever endpoint MCP_URL names. NATIVE_API_AUDIENCE = "https://mcp.native.no/mcp" TIMEOUT = timedelta(seconds=150) # above the gateway's 120 seconds class NativeError(Exception): def __init__(self, code: str, retryable: bool, body: dict[str, Any]) -> None: super().__init__(code) self.code, self.retryable, self.body = code, retryable, body class Native: """One MCP session per token. Use it from one task: async with Native() as native.""" def __init__(self) -> None: self._stack: AsyncExitStack | None = None self._session: ClientSession | None = None self._token: str | None = None async def __aenter__(self) -> "Native": return self async def __aexit__(self, *exc: object) -> None: await self._close() async def _close(self) -> None: if self._stack: await self._stack.aclose() self._stack, self._session, self._token = None, None, None async def _connect(self) -> ClientSession: token = await asyncio.to_thread(access_token, NATIVE_API_AUDIENCE, "mcp:read mcp:write") if self._session and token == self._token: return self._session # The token is set on the transport, so a new token needs a new session. await self._close() stack = AsyncExitStack() try: read, write, _ = await stack.enter_async_context( streamablehttp_client(MCP_URL, headers={"Authorization": f"Bearer {token}"}) ) session = await stack.enter_async_context(ClientSession(read, write)) await session.initialize() except BaseException: # A refused token fails here: close what was opened, then raise. await stack.aclose() raise self._stack, self._session, self._token = stack, session, token return session async def call(self, name: str, args: dict[str, Any]) -> Any: session = await self._connect() result = await session.call_tool(name, args, read_timeout_seconds=TIMEOUT) data = (result.structuredContent or {}).get("data") if not result.isError: return data # No envelope: the SDK refused the arguments before the tool ran. Fix the call. if not isinstance(data, dict) or "error" not in data: raise RuntimeError(f"{name}: arguments refused by the input schema") error = data["error"] raise NativeError(error["code"], error["retryable"], error) # Wrappers, with the fields on each tool's page. Add the others the same way. async def get_profile(self, brand_id: str) -> dict[str, Any]: return await self.call("get_profile", {"brandId": brand_id}) async def create_profile(self, website_url: str, name: str, external_id: str) -> dict[str, Any]: args = {"websiteUrl": website_url, "name": name, "externalId": external_id} return await self.call("create_profile", args) async def create_posts(self, brand_id: str, brief: str, count: int) -> str: """Starts a batch from one brief and answers at once. Store the batch id before you wait.""" args = {"brandId": brand_id, "brief": brief, "count": count} return (await self.call("create_posts", args))["batchId"] async def wait_for_batch(self, batch_id: str) -> list[str]: """Waits for a batch, polling as the flow page does, and returns the created post ids. Run it in a background job, never inside a web request. """ started, delay = time.monotonic(), 5.0 batch = await self.call("get_post_batch_status", {"batchId": batch_id}) while not batch["settled"] or (batch.get("imagesPendingCount") or 0) > 0: elapsed = time.monotonic() - started if elapsed > 15 * 60: raise TimeoutError(f"Batch {batch_id} has not finished after 15 minutes") # 5 seconds for the first minute, then 15, growing to no more than 30. if elapsed > 60: delay = min(30.0, max(15.0, delay * 1.5)) await asyncio.sleep(delay) batch = await self.call("get_post_batch_status", {"batchId": batch_id}) return [item["postId"] for item in batch["items"] if item["status"] == "created"] ``` ## 5. Add a check the person can run Add a script the person runs once their credentials are in, wired to the way the project runs scripts. Load the env file the way the app does, so the check reads the same values. The check: - Names each missing variable plainly and stops, before any network call. - Otherwise fetches a token and makes one read-only call, `get_workspaces` with `limit: 1`, and says it worked. - On a failure, says what failed: the token request’s HTTP status, or the tool’s `code`. `partner_not_found` means Native has no partner set up for the client ID yet, so the person writes to [hei@native.no](mailto:hei@native.no). - Creates, changes and publishes nothing. TypeScript: ```ts import { callNative, closeNative, NativeError } from './native-api' async function main(): Promise { const missing = ['NATIVE_CLIENT_ID', 'NATIVE_CLIENT_SECRET'].filter((name) => !process.env[name]) if (missing.length > 0) { console.log(`Native API: ${missing.join(' and ')} not set. Native issues them when your access opens.`) return 1 } try { // Read only: lists the workspaces the token can reach, and changes nothing. const { totalCount } = await callNative<{ totalCount: number }>('get_workspaces', { limit: 1 }) console.log(`Native API: signed in. The token reaches ${totalCount} workspace(s).`) return 0 } catch (err) { if (err instanceof NativeError && err.code === 'partner_not_found') { console.error('Native API: Native has no partner set up for this client ID. Write to hei@native.no.') } else if (err instanceof NativeError) { console.error(`Native API refused the call: ${err.code}`) } else { console.error(`Native API check failed: ${(err as Error).message}`) } return 1 } finally { await closeNative() } } main().then((code) => process.exit(code)) ``` Python: ```python import asyncio import os import sys from native_api import Native, NativeError async def main() -> int: missing = [n for n in ("NATIVE_CLIENT_ID", "NATIVE_CLIENT_SECRET") if not os.environ.get(n)] if missing: print(f"Native API: {' and '.join(missing)} not set. Native issues them when your access opens.") return 1 try: async with Native() as native: # Read only: lists the workspaces the token can reach, and changes nothing. result = await native.call("get_workspaces", {"limit": 1}) except NativeError as err: if err.code == "partner_not_found": print("Native API: Native has no partner set up for this client ID. Write to hei@native.no.") else: print(f"Native API refused the call: {err.code}") return 1 except Exception as err: print(f"Native API check failed: {err}") return 1 print(f"Native API: signed in. The token reaches {result['totalCount']} workspace(s).") return 0 if __name__ == "__main__": sys.exit(asyncio.run(main())) ``` If you can run commands here and the variables are still empty, run it once: it must name both and stop before any network call. Leave the first real run to the person. ## 6. Point future sessions at the docs If the project has an agent instructions file, such as `AGENTS.md`, `CLAUDE.md` or a rule file in `.cursor/rules/`, add one line to it, with the real path. In `.cursor/rules/`, add it to an existing general rule, or skip this step when none fits. Do not create such a file if there is none. ```text - Native API: docs for coding agents at https://docs.native.no/llms.txt. Our client is in . ``` ## 7. Tell the person what changed Finish with a short summary for the person: - The files you added or changed, and any package you installed with their approval. - The two variables to fill in, `NATIVE_CLIENT_ID` and `NATIVE_CLIENT_SECRET`, and the file they go in. - How to run the check. - Every TODO you left, and why. - How access opens. Partner access to the Native API is not open yet: the person asks for it through [Contact sales](https://native.no/contact-sales), and Native issues the credentials when it switches access on for them. --- # From onboarding to offboarding Source: https://docs.native.no/native-api/flow > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. The calls a partner backend makes for one client, in order: onboard, set the look, connect accounts, generate, approve, publish, track and offboard. > **Not open to partners yet.** Partner access is not live. Sign-in with client credentials is still in development, so no partner backend can call these tools yet. Tools marked Preview run in the gateway today for people signed in to Native. Tools and fields marked In development are not deployed yet. Names and fields can still change before access opens. A partner runs Native for its own clients from its backend. Each client is its own Native workspace with one brand. Every call names the client by its `brandId`, or by the id of one of its posts. This page follows one client from the first call to the last. Each step names its tools and shows a short example. Every field and error is on the tool pages: [Profiles](https://docs.native.no/native-api/profiles), [Branding](https://docs.native.no/native-api/branding), [Social accounts](https://docs.native.no/native-api/social-accounts), [Generating posts](https://docs.native.no/native-api/generating-posts), [Editing posts](https://docs.native.no/native-api/editing-posts), [Approval](https://docs.native.no/native-api/approval) and [Publishing](https://docs.native.no/native-api/publishing). ## Connect to the gateway The tools are served over MCP Streamable HTTP at `https://mcp.native.no/mcp`. Use the official MCP SDK rather than writing JSON-RPC by hand. The gateway keeps no session: each request stands on its own. Your backend signs in with an Auth0 client-credentials token. [Authentication](https://docs.native.no/authentication) covers how to get one, and its token helper, `accessToken`, is what the code below imports. Two things shape the code below: - A token lasts an hour at most. Cache it, and fetch a new one shortly before `expires_in` runs out. - The token is set when the transport is made. Make a new client with each new token. TypeScript: ```ts import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // The token helper from Authentication. import { accessToken } from './native-token' const MCP_URL = 'https://mcp.native.no/mcp' // The token's audience stays the same whichever endpoint MCP_URL names. const NATIVE_API_AUDIENCE = 'https://mcp.native.no/mcp' let cached: { token: string; client: Promise } | undefined // One client per token: the token is set on the transport, so a new token needs a new client. // The client is cached while it connects, so calls that arrive together share it. async function nativeClient(): Promise { const token = await accessToken(NATIVE_API_AUDIENCE, 'mcp:read mcp:write') if (cached?.token === token) return cached.client const client = connect(token) cached = { token, client } // A connection that fails is dropped, so the next call tries again. client.catch(() => { if (cached?.client === client) cached = undefined }) return client } async function connect(token: string): Promise { const client = new Client({ name: 'partner-backend', version: '1.0.0' }) await client.connect( new StreamableHTTPClientTransport(new URL(MCP_URL), { requestInit: { headers: { Authorization: `Bearer ${token}` } }, }), ) return client } export class NativeError extends Error { code: string retryable: boolean body: object constructor(code: string, retryable: boolean, body: object) { super(code) this.code = code this.retryable = retryable this.body = body } } // Every call goes through here, with a timeout above the gateway's 120 seconds. export async function callNative(name: string, args: Record): Promise { const client = await nativeClient() const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 150_000 }) const data = (result.structuredContent as { data?: any } | undefined)?.data if (!result.isError) return data // No envelope: the SDK refused the arguments before the tool ran. Fix the call. if (!data?.error) throw new Error(`${name}: arguments refused by the input schema`) throw new NativeError(data.error.code, data.error.retryable, data.error) } ``` Raise the timeout as shown. Most calls return within seconds, but a write that contacts a platform, such as `schedule_post` or `publish_post_now`, can take much longer. The gateway waits up to 120 seconds for Native before it answers `upstream_timeout`. The SDK’s default timeout is shorter, so without the raise your side can give up on a call that has already succeeded on ours. A failed call comes back with `isError` and an envelope holding `code`, `message` and `retryable`. Branch on `code`: Native writes `message` for people and can change it. `retryable` says whether the same call, unchanged, could succeed. [Errors](https://docs.native.no/native-api/errors) lists every code. > **A pinned version is in development.** A versioned endpoint, `https://mcp.native.no/mcp/v1`, is in development. Once it ships, partners are meant to call it instead of `/mcp`. It takes the same token, so only the URL changes. ## The steps The steps run in this order for each client. One can start early: the client can connect accounts while Native builds the profile. The examples use `callNative` from [the code above](https://docs.native.no/native-api/flow#connect-to-the-gateway). ## 1. Onboard a client MCP tool `create_profile` (in development) Send the client’s website, its business name and your own id for the client. `create_profile` makes a workspace of its own for the client, on your plan, with no browser and no checkout. Arguments: ```json { "websiteUrl": "https://pipedream.example", "name": "Pipe Dream Plumbing", "externalId": "client-1042" } ``` Native answers at once with the `brandId` that every other tool takes. It builds the profile in the background: it reads the website and fills in the business profile, the brand’s look, its knowledge, its products and its writing style, as it does for a customer who signs up in the app. Result: ```json { "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "companyId": "c51c8e77-4123-4461-a185-c5e8475174a9", "status": "building", "created": true } ``` `externalId` makes the call safe to repeat. A second call with the same `externalId` answers with the profile the first call made, with `created: false`, whatever else it sends. `allowTeamPhotos` is off by default. Leave it off unless the client has agreed. It lets Native keep pictures of the client’s staff from the website and recognise them in pictures you upload. Send the client’s own website, not a social media profile. A social media address is refused with `invalid_website_url`, and nothing is created. ## 2. Wait until the profile is ready MCP tool `get_profile` (preview) A build typically takes 10 to 30 minutes. Poll `get_profile` with the `brandId` about once a minute, until `onboarding.status` is `ready` or `failed`. The `onboarding` field is in development. It appears only on a profile that `create_profile` made. Result while the profile builds, shortened: ```json { "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "brandName": "Pipe Dream Plumbing", "isActive": true, "channels": [], "readiness": { "canGenerate": false, "canPublish": false, "blockers": [ "profile_not_ready", "no_channel_connected" ] }, "onboarding": { "status": "building" } } ``` Until the build is `ready`, the tools that generate refuse the profile with `profile_not_ready`: `create_posts`, `generate_caption`, `generate_image`, `regenerate_post_image`, ad generation, and `create_draft` when it is sent a `prompt` or `includeImage: true`. That includes a profile whose build has failed. A `create_draft` with your own caption is not refused. When a build fails, `onboarding.error` says why: `website_unreachable`, `analysis_failed`, `enrichment_failed`, `build_not_queued` or `build_interrupted`. Treat the list as open, since more codes can come. To retry, call `create_profile` again with the same `externalId`. The build restarts where it stopped. ## 3. Set the brand’s look MCP tool `get_brand_style` (in development) MCP tool `update_brand_style` (in development) MCP tool `upload_brand_logo` (in development) The build takes the brand’s colours and fonts from the website. Once the profile is ready, read what it found with `get_brand_style`, logos included, and correct what is wrong. `update_brand_style` adds palette colours, sets the colour roles and sets the fonts. Pass only what changes: everything you leave out stays as it is. A font must be in the brand’s library or be a Google Fonts family. Otherwise the call fails with `font_not_found`, and no role changes. Arguments: ```json { "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "colors": { "background": "#F7F4EE", "accent": "#1F5FAD", "text": "#1B1D21", "secondary": "auto" }, "fonts": { "heading": "Fraunces", "body": "Inter" } } ``` `upload_brand_logo` saves a logo from a public https URL or as base64. By default it also makes it the logo Native uses in images and ads. The logo it replaces stays among the brand’s logos. > **Set the look before you generate.** A style change never redraws a post that already exists, approved and scheduled posts included. Only posts made after the change carry the new look. ## 4. Connect the client’s social accounts MCP tool `create_connect_link` (preview) MCP tool `get_connect_options` (in development) MCP tool `complete_connect` (in development) `create_connect_link` mints a single-use link that the client opens to connect an account. It expires about 10 minutes after you mint it. Hand it to the client, and do not load it yourself: loading it uses it up. Today the link opens Native’s connect page. Two parameters are in development. `platform` makes the link headless: it goes straight to the platform’s own login and consent screen. A headless link needs `returnUrl`, an https URL of yours. When the client is done, the browser comes back to it. Arguments for a headless link: ```json { "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "platform": "instagram", "returnUrl": "https://app.partner.example/native/connected?client=client-1042" } ``` If Native cannot make a headless link for the brand, the call fails with `headless_connect_unavailable`. Call it again without `platform` and `returnUrl` for a link to Native’s connect page. Native adds `status` to your `returnUrl`, and replaces any `status`, `platform`, `connectToken` or `error` already on it: | `status` | Also on the URL | What to do | | --- | --- | --- | | `connected` | `platform` | Confirm with `get_profile`. | | `selection_required` | `platform`, `connectToken` | The client manages several Facebook Pages, LinkedIn organisations, Pinterest boards or Google Business locations. Pass `connectToken` to `get_connect_options`, let the client choose, then call `complete_connect` with their choice. | | `failed` | `platform`, `error` | `error` is a code such as `oauth_denied`, `link_expired` or `account_in_another_workspace`. Mint a fresh link to retry. | `complete_connect` replaces any account the brand already had on that platform. Call it only with the choice the client made. The `connectToken` lasts about 10 minutes. After that, both tools fail with `connect_session_expired`, and the client needs a fresh link. Then confirm the connection. Call `get_profile` once, then every 10 seconds for up to 2 minutes, until the channel’s `health` is `connected` and `readiness.canPublish` is `true`. A `failed` status needs a fresh link, not more polling. ## 5. Generate posts MCP tool `create_posts` (preview) MCP tool `get_post_batch_status` (preview) `create_posts` turns one brief into 1 to 20 draft posts. Each takes a different angle and gets copy written for each destination. Leave out `platforms` to use the brand’s defaults. Destinations here are uppercase names such as `INSTAGRAM`. `get_profile` and `create_connect_link` use lowercase names such as `instagram`. Arguments: ```json { "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "brief": "Frozen pipes: how to prevent them this winter, and that we come out the same day, seven days a week.", "count": 3, "platforms": [ "INSTAGRAM", "FACEBOOK" ] } ``` It answers at once with a `batchId`, and the posts are written in the background. Poll `get_post_batch_status` every 5 seconds for the first minute, then every 15 seconds, growing to no more than 30. The batch is finished when `settled` is `true` and `imagesPendingCount` is 0. `settled` alone covers the copy: pictures can still be rendering. TypeScript: ```ts const { batchId } = await callNative('create_posts', { brandId, brief, count: 3 }) const started = Date.now() let delay = 5_000 let batch = await callNative('get_post_batch_status', { batchId }) while (!batch.settled || (batch.imagesPendingCount ?? 0) > 0) { const elapsed = Date.now() - started if (elapsed > 15 * 60_000) throw new Error(`Batch ${batchId} has not finished after 15 minutes`) // 5 seconds for the first minute, then 15, growing to no more than 30. if (elapsed > 60_000) delay = Math.min(30_000, Math.max(15_000, delay * 1.5)) await new Promise((resolve) => setTimeout(resolve, delay)) batch = await callNative('get_post_batch_status', { batchId }) } const postIds = batch.items .filter((item: { status: string }) => item.status === 'created') .map((item: { postId: string }) => item.postId) ``` A batch can partly fail. Each item has its own `status`, and a failed item carries an `error` with a `code`. If a batch has not finished after about 15 minutes, stop and raise an alert. Created items will carry their destinations, `imageUrls`, `contentDigest` and `approval` (in development), so you can show and approve a post without reading it again. Until then, read each created post with `view_post`, passing its `postId` as `id`. `scheduledAt` on `create_posts` only records an intended time. It does not schedule anything. Generation spends the Native tokens on your plan. When they run out, the call fails with `usage_limit_exceeded`, and `resetDate` says when they refill. ## 6. Edit posts MCP tool `update_post` (preview) MCP tool `generate_caption` (preview) MCP tool `regenerate_post_image` (preview) MCP tool `get_generation_job` (preview) `update_post` overwrites the caption, the first comment or the destinations. Set `platform` to change one destination’s caption or first comment only. No history is kept: when the call overwrites something, `replaced` in the result holds the previous values. `generate_caption` rewrites a caption in the brand’s voice and replaces the current one. `instructions` steers the rewrite. `regenerate_post_image` redraws one picture and leaves the other slides alone. It answers at once with `imageGeneration.jobId`. Poll `get_generation_job` with it until the job is `completed` or `failed`. TypeScript: ```ts const { imageGeneration } = await callNative('regenerate_post_image', { postId, index: 0, prompt: 'The same van, parked outside a house in fresh snow', }) let job do { // Each call waits up to about 24 seconds for the render to settle. job = await callNative('get_generation_job', { jobId: imageGeneration.jobId }) } while (job.status !== 'completed' && job.status !== 'failed') ``` Do not call `regenerate_post_image` again to retry a render: poll the job you have. While a render is running, a second call is refused with `image_generation_in_progress`, which carries the running render’s `jobId`. Once it has ended, a second call starts a new render. Template rendering is in development, and partner workspaces will have it on by default. On a workspace that renders posts from design templates, `regenerate_post_image` will be refused with `template_rendering`. Use `generate_image` there: it redraws the post’s whole design, replaces all its pictures and keeps the slide text exact. These pages do not cover `generate_image` yet: `tools/list` has its schema. A template render takes a few minutes. Every change to the content changes the post’s `contentDigest`, so an approval given before the change no longer holds: scheduling and publishing refuse the post with `approval_stale`. After editing, read the post again with `view_post` and have that version reviewed. A published post cannot be edited: edits are refused with `post_already_published`. ## 7. Get the client’s approval MCP tool `view_post` (preview) MCP tool `approve_post` (preview) MCP tool `reject_post` (preview) Show the client the post as `view_post` returns it. Each entry in `destinations` holds the caption, first comment, title, pictures and publishing options that will publish on that channel. Keep the `contentDigest` from that same read. When the client approves, call `approve_post` with that digest as `expectedDigest`. Name the person who approved in `reviewerName` and `reviewerEmail`. Without them, Native records the caller as the reviewer. TypeScript: ```ts const post = await callNative('view_post', { id: postId }) // Show post.destinations to the client. When they approve: try { await callNative('approve_post', { id: postId, expectedDigest: post.contentDigest, reviewerName: 'Dana Brooks', reviewerEmail: 'dana@pipedream.example', }) } catch (err) { if (!(err instanceof NativeError) || err.code !== 'post_content_changed') throw err // The post changed after the client saw it. Read it again and show the new version. } ``` If the post changed after your read, the approval is refused with `post_content_changed`. Read the post again and show the client the new version. Never resend with a newer digest the client did not see. When the client turns a post down, call `reject_post` with their `reason`. While the latest decision is a rejection, scheduling and publishing refuse the post with `approval_rejected`, even after edits. Only a new `approve_post` clears it. By default, partner workspaces will require an approval before anything is scheduled or published (in development). A post with no decision on record will be refused with `approval_required`. The digest does not cover the scheduled time. Moving an approved post to another time does not need a new approval. One limit to know: on a video post, the digest covers the subtitle files, but the API cannot show subtitles yet. A subtitle change makes an approval stale with no change your reviewer can see. ## 8. Schedule or publish MCP tool `schedule_post` (preview) MCP tool `reschedule_posts` (preview) MCP tool `publish_post_now` (preview) MCP tool `unschedule_platform` (preview) `schedule_post` books a post, or some of its destinations, for a time. It also moves a post that is already booked, and the post stays booked throughout. `reschedule_posts` books or moves up to 25 posts in one call. `publish_post_now` publishes at once. Arguments: ```json { "id": "faa2595d-41e8-4645-8332-4f4f9df9e9b8", "scheduledAt": "2026-11-02T09:00:00-05:00" } ``` Times are ISO 8601, with `Z` or an offset. The gateway converts them to UTC. Report a post as scheduled only when `scheduleConfirmed` in the result is `true`. Before it contacts any platform, `schedule_post` checks each destination. If one fails, for example with a caption that is too long or a picture outside Instagram’s aspect band, the call fails with `schedule_preflight_failed`. Nothing is booked, and `blockedDestinations` says what to fix. A dry run is in development. With `dryRun: true`, `schedule_post` and `publish_post_now` run every check and book or publish nothing. `blocked` lists the destinations that would fail. An empty list is not a guarantee: a platform can still refuse when the post goes out. `reschedule_posts` runs each entry on its own. A refused entry shows its error in `moves[].error`, and the others still run. Do not resend the whole list. `publish_post_now` is safe to call again. It retries only the destinations that failed and never sends a published one twice. A destination that comes back `pending` with `publishing_in_progress` is still finishing at the platform, as Instagram routinely does. Do not retry it: poll `get_post_status`. To pull one destination back from its booking, call `unschedule_platform` with its `platformPostId`, which `get_post_status` returns. A post with three destinations takes three calls. The destination keeps its date as a draft. > **Read before you retry a write.** A resent call is a new attempt. The gateway does not deduplicate it, and idempotency keys of your own are not available yet. After a timeout or a dropped connection on `create_posts`, `schedule_post` or `publish_post_now`, read the current state first. A lost response does not prove that nothing happened. ## 9. Track what happens MCP tool `get_post_status` (preview) MCP tool `get_profile` (preview) Native does not retry a failed publish by itself, and there are no webhooks yet. Poll `get_post_status`. It reports each destination’s `state`, its live `url` and, on a failure, an `error` with a `code` and `retryable`. | After | How often | Stop when | | --- | --- | --- | | A scheduled time | Once a few minutes after `scheduledAt`, then every 5 minutes for 30 minutes | Every destination is `published` or `failed` | | `publish_post_now` | Every 10 seconds for 2 minutes, then every minute for 10 minutes | Every destination is `published` or `failed` | A destination in `stuck` was accepted, its time passed and no outcome came back. Stop polling and contact Native support: a retry could post it twice. When a destination fails with `channel_needs_reauth` or `channel_not_connected`, send the client a fresh connect link, then retry. For other codes, follow `retryable`. Check channel health with `get_profile` every day and before you schedule. Act on `needs_reauth` and `disconnected`. Act on `expiring_soon` too: the channel still publishes, but only until its token runs out. ## 10. Offboard the client MCP tool `deactivate_profile` (in development) MCP tool `delete_profile` (in development) `deactivate_profile` ends service and deletes nothing. Every scheduled post goes back to unscheduled and keeps its date. The client’s social accounts are disconnected and removed at the provider. Every profile in the workspace becomes inactive, and the workspace loses its plan, so nothing can be generated, scheduled or published for it. There is no tool to reactivate a client. Reconnecting means the client logs in with each platform again. `delete_profile` erases the client permanently: the workspace, every profile in it, its posts, pictures, videos and uploaded files, its brand settings and its onboarding data. There is no undo, and Native keeps no copy. Use it for a GDPR erasure request. Afterwards, the client’s image URLs stop working. delete_profile arguments: ```json { "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "confirm": true } ``` Both run in the background. The first call schedules the work. Call again with the same arguments about every 30 seconds, until `status` is `deactivated` or `deleted`. That usually takes minutes. Repeating either call is always safe. Both refuse with `stripe_billed_workspace` when the workspace pays for its own Native subscription. That subscription has to be cancelled in Native. ## How long things take | Wait | How long | How to follow it | | --- | --- | --- | | Profile build | Typically 10 to 30 minutes | `get_profile`, about once a minute | | Connect link | Expires about 10 minutes after you mint it. A `connectToken` lasts about as long | Mint a fresh link when it runs out | | Post generation | Raise an alert after about 15 minutes | `get_post_batch_status`, every 5 to 30 seconds | | Image render | A template render (in development) takes a few minutes | `get_generation_job`, which waits up to about 24 seconds per call | | One call | Up to 120 seconds for a write that contacts a platform | A client timeout above that, such as 150 seconds | | Offboarding | Usually minutes | The same call again, every 30 seconds | ## What is not there yet - **Partner access.** Sign-in with client credentials and the pinned `/mcp/v1` endpoint are in development. - **Webhooks.** They are planned for finished generation, approval changes, publish results and disconnected accounts. Until then, poll. - **Idempotency keys of your own.** A resent call is a new attempt. Read the state before you retry a write. - **Usage per client.** A partner usage endpoint is planned. It is not available yet. - **Subtitles.** The digest covers a video’s subtitle files, but the API cannot show them yet. ## Where to go next - [Overview](https://docs.native.no/native-api): What the Native API is for, and what is ready. - [Authentication](https://docs.native.no/authentication): Getting and caching a client-credentials token. - [Errors](https://docs.native.no/native-api/errors): Every error code, and whether a retry can help. - [Limits and versions](https://docs.native.no/native-api/limits): Batch sizes, call times, upload sizes, the Native tokens on your plan, and versions. --- # Profiles Source: https://docs.native.no/native-api/profiles > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Create a profile for each client from its website, wait for Native to build it, check what it can do, and end the service when the client leaves. > **Partner access is not live yet.** You cannot call these tools with a partner token yet. Partner sign-in with client credentials is still in development. `get_profile`, `list_brands` and `get_workspaces` serve signed-in Native users today. `create_profile`, `deactivate_profile`, `delete_profile` and the `onboarding` field are in development, so their shapes can still change. A profile is one client’s presence on Native: its brand settings, its connected social accounts and its posts. The tools call it a brand, so a profile’s id is the `brandId` that every other tool takes. Each client gets a workspace of its own with one profile in it. The workspace’s id is `companyId`. You make the profile with `create_profile`, watch it with `get_profile`, and end the service with `deactivate_profile` or `delete_profile`. ## The life of a profile | Step | Call | What happens | | --- | --- | --- | | Create | `create_profile` | Native answers at once with a `brandId` and starts building the profile from the website. | | Wait | `get_profile` | Poll about once a minute until `onboarding.status` is `ready` or `failed`. It usually takes 10 to 30 minutes. | | Use | `get_profile` | Check `readiness` before you generate or schedule. Check channel health once a day. | | End | `deactivate_profile` or `delete_profile` | Native withdraws scheduled posts and disconnects the social accounts in the background. Delete also erases everything Native holds for the client. | You can create [connect links](https://docs.native.no/native-api/social-accounts) for the client’s social accounts while the profile builds. The whole sequence, from onboarding to publishing, is in [the flow](https://docs.native.no/native-api/flow). ## Create a profile MCP tool `create_profile` (in development) Creates a profile for a client from its website. Native makes a workspace of its own for the client, on your plan, with no browser and no checkout. It answers at once and builds the profile in the background. It reads the website and fills in the business profile, the brand look, knowledge, products and writing style, the same as for a customer who signs up in the Native app. Only a partner token can call it. A token without partner access does not see it in `tools/list`, and calling it anyway fails as an unknown tool. It changes data, so the token needs the `mcp:write` scope (see [Authentication](https://docs.native.no/authentication)). Input: - `websiteUrl` (string, required): The client’s own website, such as `https://harbourstreetbakery.example`. A bare domain is accepted. Not a social media profile. 1 to 2,048 characters. - `name` (string, required): The client’s business name. Native uses it for the workspace and its profile. 1 to 100 characters. - `externalId` (string, required): Your own id for this client, 1 to 200 characters. It makes the call idempotent, and it is the only way to retry a failed build. See [Calling it again](https://docs.native.no/native-api/profiles#calling-again). - `allowTeamPhotos` (boolean, optional; default: `false`): Lets Native read and keep pictures of the client’s staff from the website, and recognise them in pictures you upload later. Leave it off unless the client has agreed. Output: - `brandId` (string, required): The profile’s id. Every other tool takes it. - `companyId` (string, required): The id of the client’s workspace. - `status` (string, required; one of: `building`, `ready`, `failed`): `building` on a new profile, and after a repeat that restarts a failed build. On any other repeat, the build’s current state. `failed` here means Native could not start the build. - `error` (string, optional): Present only when `status` is `failed`. One of the [build failure codes](https://docs.native.no/native-api/profiles#build-failures), in practice `build_not_queued`. Treat it as an open set. - `created` (boolean, required): `false` when the call answered with the profile an earlier call with the same `externalId` made. Create a profile: ```ts import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; // client: an MCP SDK client connected with a partner token. async function createProfile(client: Client) { const result = await client.callTool( { name: "create_profile", arguments: { websiteUrl: "https://harbourstreetbakery.example", name: "Harbour Street Bakery", externalId: "client-4821", }, }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any } | undefined)?.data; if (result.isError) { // data = { ok: false, error: { code, message, retryable, ... } } throw new Error("create_profile failed: " + data.error.code); } // Keep these next to your own id for the client. return { brandId: data.brandId, companyId: data.companyId, status: data.status }; } ``` structuredContent: ```json { "data": { "brandId": "3f6c2a9e-5b7d-4e1a-9c2f-7a1d8e4b6c05", "companyId": "b8e1d0c4-2f3a-4c5d-9e6f-0a1b2c3d4e5f", "status": "building", "created": true } } ``` ### Calling it again `externalId` makes the call idempotent. A repeat with the same `externalId` answers with the profile the first call made, with `created: false`, whatever else it sends. It never makes a second workspace for the same `externalId`. A repeat is also the only way to retry a failed build. Call again with the same `externalId` and the build restarts where it stopped. The answer then says `building` again. A repeat always builds from the website the first call sent, so it cannot fix a wrong address. No partner tool can change a profile’s website yet. Contact Native if the first address was wrong. ### Errors | Code | Retryable | Meaning | | --- | --- | --- | | `partner_only` | `false` | Native refused the call because the caller is not a partner. Nothing was created. | | `invalid_website_url` | `false` | Native refused the website before creating anything. `websiteRefusal` says why. Send the client’s own website. | | `bad_request` | `false` | Native could not accept the input, such as a `websiteUrl` that is not a valid website address. `invalidFields` names the fields to fix, and `fieldErrors` says why when it is present. | `invalid_website_url` carries one of these codes in `websiteRefusal`. The field is left out when Native sends a reason the gateway does not know. | Code | Meaning | | --- | --- | | `BLOCKED_DOMAIN` | A social media or platform address, not the client’s own site. | | `URL_NOT_REACHABLE` | The address does not resolve, or it refused the connection. | | `URL_UNSAFE` | The address is flagged as harmful. | | `URL_NOT_ALLOWED` | The address points at a private network address. | structuredContent on a refusal: ```json { "data": { "ok": false, "error": { "code": "invalid_website_url", "message": "That website address was refused, so no profile was created; websiteRefusal says why: BLOCKED_DOMAIN is a social media or platform address rather than the customer's own site, URL_NOT_REACHABLE does not resolve or refused the connection, URL_UNSAFE is flagged as harmful, URL_NOT_ALLOWED points at a private address. Send the customer's own website", "retryable": false, "websiteRefusal": "BLOCKED_DOMAIN" } } } ``` It can also return the [errors every tool can return](https://docs.native.no/native-api/profiles#common-errors). ## Wait for the build While the build runs, `get_profile` reports `onboarding.status` as `building`, `readiness.canGenerate` as `false`, and the blocker `profile_not_ready`. Until the build is `ready`, every tool that generates refuses the profile with `profile_not_ready`: `create_posts`, `generate_caption`, `generate_image`, `regenerate_post_image`, ad generation, and `create_draft` when it is sent a `prompt` or `includeImage: true`. That includes a profile whose build has failed. A `create_draft` with your own caption is not refused. Connecting the client’s accounts and publishing are not held up either. Poll `get_profile` about once a minute until `onboarding.status` is `ready` or `failed`. It usually takes 10 to 30 minutes. `profile_not_ready` is retryable, so a call refused for it can be made again unchanged once the profile is ready. get_profile while the build runs: ```json { "data": { "brandId": "3f6c2a9e-5b7d-4e1a-9c2f-7a1d8e4b6c05", "brandName": "Harbour Street Bakery", "companyId": "b8e1d0c4-2f3a-4c5d-9e6f-0a1b2c3d4e5f", "isActive": true, "defaultPlatform": null, "generationPlatforms": [], "storyDestinations": [], "socialProvider": "ZERNIO", "channels": [], "readiness": { "canGenerate": false, "canPublish": false, "blockers": [ "profile_not_ready", "no_channel_connected" ] }, "onboarding": { "status": "building" } } } ``` Wait for the build: ```ts import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitForProfile(client: Client, brandId: string) { for (;;) { const result = await client.callTool( { name: "get_profile", arguments: { brandId } }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any } | undefined)?.data; if (result.isError) { if (!data.error.retryable) throw new Error("get_profile failed: " + data.error.code); } else { const onboarding = data.onboarding; // No onboarding field: create_profile did not build this profile. if (!onboarding || onboarding.status === "ready") return data; if (onboarding.status === "failed") { // Retry: call create_profile again with the same externalId. throw new Error("build failed: " + onboarding.error); } } await sleep(60_000); } } ``` ### When a build fails A failed build reports a code in `onboarding.error`. Treat the list as open: Native can add codes. A repeat `create_profile` restarts the build and answers `building`, so read the code from `get_profile` before you retry. | Code | Meaning | | --- | --- | | `website_unreachable` | Nothing could read the website. A retry helps only once the site answers again. | | `analysis_failed` | Native read the website, but the analysis of it failed. | | `enrichment_failed` | Native could not fill in the profile from the analysis. | | `build_not_queued` | Native could not start the next step of the build. | | `build_interrupted` | The build stopped without finishing, for example because the server running it restarted. | Every one of them is retried the same way: call `create_profile` again with the same `externalId`. The build picks up where it stopped. ## Get a profile MCP tool `get_profile` (preview) Shows what a profile can do right now: its connected channels and their health, the channels it can publish to, and whether it can generate and publish. Call it while a build runs, before you schedule, and once a day to catch accounts that need reconnecting. Input: - `brandId` (string, required): The profile’s id. - `includeScopes` (boolean, optional; default: `false`): Also fetch the OAuth scopes each channel granted. It costs one call to the social provider per channel, so leave it off for routine checks. Output: - `brandId` (string, required): The profile’s id. - `brandName` (string, required): The profile’s name. - `companyId` (string, required): The workspace the profile belongs to. - `isActive` (boolean, required): Whether the profile is active. An inactive profile cannot generate or publish. - `defaultPlatform` (string | null, required): Where a post goes when a call names no destinations, as an uppercase destination name such as `INSTAGRAM`. `null` when none is set. - `generationPlatforms` (string[], required): The channels the profile can publish to right now, in lowercase, such as `instagram`. A channel that needs reconnecting is left out. - `storyDestinations` (string[] | null, optional; one of: `instagram_stories`, `facebook_stories`): The story destinations the profile can publish to, in lowercase. Stories use the base Instagram or Facebook connection and never appear as channels of their own. May be absent. - `socialProvider` (string, required): The provider Native publishes through for this profile, such as `ZERNIO`. - `channels` (object[], required): The connected social accounts, one per channel. - `platform` (string, required): The channel, in lowercase, such as `instagram`. - `displayName` (string | null, required): The account’s display name. - `username` (string | null, required): The account’s username. - `profileUrl` (string | null, required): A link to the account. - `health` (string, required; one of: `connected`, `expiring_soon`, `needs_reauth`, `disconnected`): Whether the account can publish. See [Channel health](https://docs.native.no/native-api/profiles#channel-health). - `healthReason` (string | null, required): A code saying why `health` is not `connected`, such as `token_expired_or_revoked` or `token_refresh_due`. `null` when it is `connected`. Treat it as an open set. - `connectedAt` (ISO 8601 string | null, required): When the account was connected. - `tokenRefreshInDays` (number | null, required): Days until the account’s token has to be refreshed. `null` means the provider does not report it, not that it never expires. - `tokenExpiresAt` (ISO 8601 string | null, optional): When the account’s token expires, as the provider reports it. `null` means not reported. May be absent. - `scopes` (string[] | null, required): The OAuth scopes the account granted. Filled only with `includeScopes`. `null` means unknown, not none. - `missingScopes` (string[] | null, optional): Scopes Native needs that the account has not granted. `null` when the scopes are unknown. May be absent. - `canPost` (boolean | null, optional): The provider’s own verdict on whether the account can post. `null` when the provider gave none. May be absent. - `canFetchAnalytics` (boolean | null, optional): The provider’s own verdict on whether Native can read the account’s analytics. `null` when the provider gave none. May be absent. - `readiness` (object, required): Whether the profile can generate and publish now, and what stands in the way. - `canGenerate` (boolean, required): Whether Native can generate posts for the profile now. - `canPublish` (boolean, required): Whether the profile can publish now: it is active, has brand settings and has at least one channel it can publish to. - `blockers` (string[], required): What stands in the way, as codes. Empty when nothing does. See [Readiness blockers](https://docs.native.no/native-api/profiles#readiness-blockers). - `onboarding` (object, optional): New, in development. How the build `create_profile` started is going. Only on a profile `create_profile` made, and absent for every other profile. - `status` (string, required; one of: `building`, `ready`, `failed`): `building` while Native reads the website and fills in the profile. - `error` (string, optional): Present only when `status` is `failed`. One of the [build failure codes](https://docs.native.no/native-api/profiles#build-failures). The output can hold more fields than these. Ignore any you do not use. A profile `create_profile` built, once it is ready and has one Instagram account connected. The `onboarding` field is in development. structuredContent: ```json { "data": { "brandId": "3f6c2a9e-5b7d-4e1a-9c2f-7a1d8e4b6c05", "brandName": "Harbour Street Bakery", "companyId": "b8e1d0c4-2f3a-4c5d-9e6f-0a1b2c3d4e5f", "isActive": true, "defaultPlatform": "INSTAGRAM", "generationPlatforms": [ "instagram" ], "storyDestinations": [ "instagram_stories" ], "socialProvider": "ZERNIO", "channels": [ { "platform": "instagram", "displayName": "Harbour Street Bakery", "username": "harbourstreetbakery", "profileUrl": "https://www.instagram.com/harbourstreetbakery", "health": "connected", "healthReason": null, "connectedAt": "2026-10-12T09:41:07.000Z", "tokenRefreshInDays": 58, "tokenExpiresAt": "2026-12-09T09:41:07.000Z", "scopes": null, "missingScopes": null, "canPost": true, "canFetchAnalytics": true } ], "readiness": { "canGenerate": true, "canPublish": true, "blockers": [] }, "onboarding": { "status": "ready" } } } ``` ### Readiness blockers `readiness.blockers` holds codes, not sentences. Branch on them, and treat the list as open: Native can add codes. | Blocker | Means | What to do | | --- | --- | --- | | `brand_inactive` | The profile is inactive. | Nothing generates or publishes for it. | | `no_brand_profile` | The profile has no brand settings. | Nothing generates or publishes for it. Contact Native. | | `profile_not_ready` | New, in development. `create_profile` is still building the profile, or its build failed. `onboarding` says which. | Wait for the build, or retry it with `create_profile` and the same `externalId`. | | `no_channel_connected` | No publishing account is connected. | Send the client a connect link from `create_connect_link`. | | `all_channels_need_reauth` | Every publishing account needs reconnecting. | Send the client a connect link for each one. | | `all_channels_disconnected` | Every publishing account was removed on purpose. | There is nothing to reconnect. The client has to connect an account again. | | `connection_check_failed` | Native could not reach the social provider, so the connection state is unknown. | Call again later. Do not ask the client to connect: the accounts may be fine. | ### Channel health | Health | Publishes | Means | | --- | --- | --- | | `connected` | Yes | The account works. | | `expiring_soon` | Yes | The token is due for refresh within 14 days. `tokenRefreshInDays` counts them down. Ask the client to reconnect before it reaches 0, or publishing will stop. | | `needs_reauth` | No | The client has to reconnect the account. | | `disconnected` | No | The account was removed. | `get_profile` returns only the [errors every tool can return](https://docs.native.no/native-api/profiles#common-errors). A `brandId` that does not exist, or is not yours, gets `not_found` or `forbidden`. ## Find a client’s profile again Keep the `brandId` and `companyId` that `create_profile` returns next to your own `externalId`. Calling `create_profile` again with the same `externalId` gives them back too, but it is not a plain read: it restarts a failed build. Two existing tools list what a token can reach. New, in development: with a partner token, they list only your own clients’ workspaces. That scoping arrives with partner access. ### List workspaces MCP tool `get_workspaces` (preview) Input: - `query` (string, optional): Only workspaces whose name or slug contains this text, ignoring case. 1 to 200 characters. - `limit` (integer, optional; default: `50`): How many workspaces to return, from 1 to 200. Output: - `workspaces` (object[], required): The workspaces that matched, up to `limit`. - `id` (string, required): The workspace’s id: a `companyId`. - `name` (string, required): The workspace’s name. - `slug` (string, required): The workspace’s slug. - `logo` (string | null, required): A link to the workspace’s logo. - `role` (string, required): The caller’s role in the workspace, such as `OWNER`. - `membershipType` (string | null, required): The workspace’s plan, such as `PRO`. `null` when it has none. - `numBrands` (integer, required): How many profiles the workspace holds. - `totalCount` (integer, required): How many workspaces matched, before `limit`. - `truncated` (boolean, required): Whether more workspaces matched than were returned. - `needsUserChoice` (boolean, required): Whether more than one workspace matched. It tells an assistant to ask a person which one they mean. A backend can ignore it. There is no cursor. A call returns at most 200 workspaces, and `truncated` says when more matched. With more clients than that, narrow with `query`, or look profiles up in your own records. ### List a workspace’s profiles MCP tool `list_brands` (preview) Input: - `workspaceId` (string, required): The workspace’s id: the `companyId` from `create_profile` or `get_workspaces`. Output: - `workspaceId` (string, required): The workspace you asked about. - `workspaceName` (string | null, required): The workspace’s name. - `brands` (object[], required): The profiles in the workspace that the caller can reach. - `id` (string, required): The profile’s id: its `brandId`. - `name` (string, required): The profile’s name. - `slug` (string, required): The profile’s slug. - `logo` (string | null, required): A link to the profile’s logo. - `userRole` (string | null, required): The caller’s role on this profile, such as `ADMIN`. - `isActive` (boolean, required): Whether the profile is active. - `publishReadiness` (string, required; one of: `ready`, `not_connected`, `unknown`): A quick verdict that costs no call to the social provider. `ready`: it has a live publishing connection. `not_connected`: nothing can be scheduled until an account is connected. `unknown`: the provider holds the answer, so call `get_profile`. - `needsUserChoice` (boolean, required): Whether the workspace holds more than one profile. It tells an assistant to ask a person which one they mean. A client workspace `create_profile` made holds one, so it is `false` there. Both tools return only the [errors every tool can return](https://docs.native.no/native-api/profiles#common-errors). `list_brands` answers `forbidden` for a workspace that is not yours. ## End the service Two tools end the service for a client. Both run in the background. The first call schedules the work and answers at once. Calling again with the same `brandId` reports progress, so both are safe to repeat. Poll about every 30 seconds until the status is final. It usually takes a few minutes. Both are partner only, and both need the `mcp:write` scope. As with `create_profile`, a token without partner access does not see them in `tools/list`, and calling one anyway fails as an unknown tool. Both refuse with `stripe_billed_workspace` when the workspace pays for its own Native subscription: that subscription has to be cancelled in Native. ### Deactivate or delete | What | Deactivate | Delete | | --- | --- | --- | | Scheduled posts | Withdrawn to unscheduled. Each keeps its date. | Erased with everything else. | | Social accounts | Disconnected and removed at the provider. | Disconnected and removed at the provider first. | | The workspace | Every profile in it is made inactive, and it loses its plan. | Erased, with every profile in it. | | Posts, pictures and files | Kept. | Erased: posts, pictures, videos, uploaded files, brand settings and onboarding data. Image URLs stop resolving. | | Undo | There is no reactivate tool. Reconnecting needs the client to log in with each platform again. | None. Native keeps no copy. | | Use it when | The client stops, and may come back. | The client asks to be erased, such as a GDPR erasure request. | ## Deactivate a profile MCP tool `deactivate_profile` (in development) Ends the service for a client. Every scheduled post is withdrawn to unscheduled, and keeps its date. The client’s social accounts are disconnected and removed at the provider. Every profile in the workspace is made inactive, and the workspace loses its plan, so no tool can generate, schedule or publish for it. Nothing is deleted. But there is no reactivate tool, and reconnecting needs the client to log in with each platform again. Treat it as the end of the service. Input: - `brandId` (string, required): The `brandId` `create_profile` returned. Must be a UUID. Output: - `companyId` (string, required): The workspace being deactivated. - `status` (string, required; one of: `deactivation_scheduled`, `deactivated`): `deactivation_scheduled` until the work has finished. Call again to poll. - `unscheduledPosts` (number, required): How many scheduled posts have been withdrawn so far. Final once `status` is `deactivated`. First call: ```json { "data": { "companyId": "b8e1d0c4-2f3a-4c5d-9e6f-0a1b2c3d4e5f", "status": "deactivation_scheduled", "unscheduledPosts": 0 } } ``` A few minutes later: ```json { "data": { "companyId": "b8e1d0c4-2f3a-4c5d-9e6f-0a1b2c3d4e5f", "status": "deactivated", "unscheduledPosts": 4 } } ``` ### Errors | Code | Retryable | Meaning | | --- | --- | --- | | `partner_only` | `false` | The caller is not a partner. Nothing was changed. | | `not_found` | `false` | No profile has this `brandId`. | | `forbidden` | `false` | The profile is in a workspace you do not manage. | | `stripe_billed_workspace` | `false` | The workspace pays for its own Native subscription. Cancel it in Native. Nothing was changed. | | `internal_error` | `true` | Call again shortly. If Native recorded the request but could not start the work, the repeat starts it. | ## Delete a profile MCP tool `delete_profile` (in development) > **This cannot be undone.** There is no restore, and Native keeps no copy. Call it only when the client has asked to be erased. Use `deactivate_profile` if the client may come back. Permanently erases a client: the whole workspace, every profile in it, its posts, pictures, videos and uploaded files, its brand settings and its onboarding data. Native disconnects the social accounts and removes them at the provider first. This is the call for a GDPR erasure request. After the erasure, the client’s image URLs stop resolving. Input: - `brandId` (string, required): The `brandId` `create_profile` returned. Must be a UUID. - `confirm` (boolean, required; one of: `true`): Must be `true`. It says you know the deletion is permanent. Output: - `companyId` (string, required): The workspace being erased. - `deleted` (boolean, required; one of: `true`): Always `true`: the deletion is accepted and will happen. - `status` (string, required; one of: `deletion_scheduled`, `deleted`): `deletion_scheduled` until the erasure has finished. Call again to poll. Once it reads `deleted`, later calls keep answering `deleted`, never `not_found`. Delete a profile and wait: ```ts import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // The first call schedules the erasure. Every later call reports progress. async function deleteProfile(client: Client, brandId: string) { for (;;) { const result = await client.callTool( { name: "delete_profile", arguments: { brandId, confirm: true } }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any } | undefined)?.data; if (result.isError) { if (!data.error.retryable) throw new Error("delete_profile failed: " + data.error.code); } else if (data.status === "deleted") { return data; } await sleep(30_000); } } ``` First call: ```json { "data": { "companyId": "b8e1d0c4-2f3a-4c5d-9e6f-0a1b2c3d4e5f", "deleted": true, "status": "deletion_scheduled" } } ``` ### Errors | Code | Retryable | Meaning | | --- | --- | --- | | `partner_only` | `false` | The caller is not a partner. Nothing was changed. | | `bad_request` | `false` | `confirm` is not `true`. Through the MCP endpoint you get an SDK validation error instead: the input schema accepts only `true`, so the call is refused before it reaches Native. | | `not_found` | `false` | No profile has this `brandId`, and you have not deleted one with it before. | | `forbidden` | `false` | The profile is in a workspace you do not manage. | | `stripe_billed_workspace` | `false` | The workspace pays for its own Native subscription. Cancel it in Native first. Nothing was changed. | | `internal_error` | `true` | Call again shortly. If Native recorded the request but could not start the work, the repeat starts it. | ## Errors every tool can return Any tool on this page can also return `bad_request`, `not_found`, `forbidden`, `subscription_required`, `internal_error`, `upstream_timeout`, `upstream_unavailable`, `upstream_identity_failed`, `identity_provider_unavailable` and `upstream_`. The tools that change data can return `writes_disabled`. A partner token whose client Native has not set up gets `partner_not_found` on every call. With a partner token, any call can also get `partner_downstream_auth_failed`, which is retryable. Branch on `error.code`, never on `message`, and respect `retryable`. An argument that breaks a tool’s input schema is refused by the MCP SDK before the tool runs, with a validation error instead of the error envelope. That covers an argument the tool does not take, and a `brandId` that is not a UUID on `deactivate_profile` or `delete_profile`. [Errors](https://docs.native.no/native-api/errors) explains the codes. --- # Branding Source: https://docs.native.no/native-api/branding > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Read a brand’s colours, fonts and logos, and change them. A change applies to new posts only: nothing already made is drawn again. > **Not live for partners yet.** Partner access to the Native API is not live. Partner sign-in with client credentials and the versioned endpoint `/mcp/v1` are still in development. All three tools on this page are in development: they are not deployed, and their names and fields can still change. A brand’s look is what Native draws its posts with: a palette of colours, which colour plays which role, a heading font, a body font and a logo. The tools on this page read that look and change it, one brand at a time. They use the same code as the Design style settings in the Native app. A change you make here is the change a person would make there. When Native builds a profile from a website, it reads the look from the site. So read the style first with `get_brand_style`, then change only what is wrong. ## Existing posts keep their look Nothing on this page draws an existing post again. That includes approved and scheduled posts. Only posts made after the change carry the new colours, fonts or logo. This is on purpose: a post should publish the way it was approved. ## Design style Colour roles, the heading and body fonts, the logo setting for posts and the style guide all come from the brand’s Design style. Each save makes a new version of it. Older versions are kept, and a change can be undone from Design style in the Native app. `designStyle` in `get_brand_style` says where a brand stands: | `designStyle` | What it means | What you can change | | --- | --- | --- | | `active` | The brand has a Design style. | Everything on this page. | | `not_set_up` | There is no Design style version yet. `colorRoles` and `styleGuide` are `null`, and so are the heading and body fonts. | Everything. The first `update_brand_style` that sets a role or a font creates the first version. | | `disabled` | The brand is not on Design style. Its posts are drawn from the plain palette. | Palette colours and logos. Roles and fonts are refused with `design_style_unavailable`. | | `unreadable` | The brand has a Design style, but Native cannot read it. | Palette colours and logos. Roles and fonts are refused with `design_style_unavailable`. | ### Colour roles Each role holds one colour from the palette, or `auto`. With `auto`, Native picks a colour from the palette for each post. | Role | Used for | Accepts | | --- | --- | --- | | `background` | The canvas behind a post. | A `#RRGGBB` hex or `auto`. | | `accent` | Highlights and buttons. | A `#RRGGBB` hex, `auto` or `none`. | | `text` | The text on a post. With `auto`, Native picks dark or light text by contrast with the background. | A `#RRGGBB` hex or `auto`. | | `secondary` | Supporting details. | A `#RRGGBB` hex, `auto` or `none`. | `none` means the brand has no colour in that role. Only `accent` and `secondary` can be empty. ## Read a brand’s style MCP tool `get_brand_style` (in development) Returns the brand’s palette, its colour roles, its fonts and font library, its logos, how the logo goes on posts, and a summary of its Design style. Reading changes nothing. Arguments: - `brandId` (string, required): The brand, as a UUID. Result data: - `brandId` (string, required): The brand. - `brandName` (string, required): The brand’s name. - `designStyle` (string, required; one of: `active`, `not_set_up`, `disabled`, `unreadable`): Whether the brand has a Design style. See [Design style](https://docs.native.no/native-api/branding#design-style). - `colors` (object[], required): The palette, oldest colour first, each with the roles it plays. - `id` (string, required): The colour’s id. - `hex` (string, required): The colour as a hex code, such as `#B5532A`. - `name` (string | null, required): The colour’s name, when it has one. - `roles` (string[], required; one of: `background`, `accent`, `text`, `secondary`): The roles this colour plays. Empty when it plays none. - `colorRoles` (object | null, required): Which colour plays which role. `null` unless `designStyle` is `active`. - `background` (object | string, required): The colour as `{ hex, name }`, or `auto` when Native picks one for each post. - `accent` (object | string, required): The colour as `{ hex, name }`, `auto`, or `none` for no accent. - `text` (object | string, required): The colour as `{ hex, name }`, or `auto`. - `secondary` (object | string, required): The colour as `{ hex, name }`, `auto`, or `none` for no secondary colour. - `fonts` (object, required): The fonts posts are set in, and the library they come from. - `heading` (string | null, required): The heading font family, or `null` when none is set. - `body` (string | null, required): The body font family, or `null` when none is set. - `library` (object[], required): Every font family the brand has. A role can name any of them. - `id` (string, required): The family’s id. - `name` (string, required): The family’s name, such as `Playfair Display`. - `type` (string | null, required): Where the family comes from, such as `GOOGLE` or `CUSTOM`. - `logos` (object[], required): The brand’s saved logos. The primary logo comes first, then the newest. - `id` (string, required): The logo’s id. - `url` (string, required): A public URL of the file. It does not expire. - `filename` (string, required): The file name. - `description` (string | null, required): What the logo is for, such as "For dark backgrounds". - `isPrimary` (boolean, required): `true` for the logo Native uses in images and ads. - `primaryLogoUrl` (string | null, required): The logo Native puts in images and ads: the primary saved logo, or else the logo it read from the website. `null` when the brand has no logo. - `logoOnPosts` (object, required): How the logo goes on posts. - `frequency` (string, required; one of: `always`, `sometimes`, `never`): How often a post carries the logo. Always `never` when the brand has no logo. - `placement` (string, required): Where the logo goes. `auto` lets Native find a place on each post. The fixed places today are `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center` and `bottom-right`. - `logoId` (string | null, required): The id of the saved logo the Design style picked for posts. It can be the primary logo itself. `null` when no file is picked and posts follow whichever logo is primary. - `styleGuide` (object | null, required): A summary of the Design style, as Native gives it to the models that draw posts. `null` unless `designStyle` is `active`. - `styleType` (string | null, required): The kind of picture the brand’s posts use, such as `illustration`, `template` or `typography`. `null` when Native chooses for each post. - `moodTags` (string[], required): Words for the mood of the brand’s posts. - `aestheticTags` (string[], required): Words for their look. - `personalityNote` (string, required): A free note on the brand’s personality. - `styleNote` (string | null, required): A free note on the visual style, when there is one. - `rules` (string[], required): The brand’s design rules, word for word. Result data: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "brandName": "Harbour Street Bakery", "designStyle": "active", "colors": [ { "id": "6a1f0c3e-2b7d-4d95-8e41-5c9a7b2d0f18", "hex": "#F4EDE1", "name": "Flour cream", "roles": [ "background" ] }, { "id": "9c4e2a71-0b3f-4f6d-a8e5-7d1b3c9f2e46", "hex": "#B5532A", "name": "Crust", "roles": [ "accent" ] }, { "id": "e2b8d5f4-7a19-4c03-9f6e-1a2d4b8c7e93", "hex": "#2B2B2B", "name": "Charcoal", "roles": [ "text" ] } ], "colorRoles": { "background": { "hex": "#F4EDE1", "name": "Flour cream" }, "accent": { "hex": "#B5532A", "name": "Crust" }, "text": { "hex": "#2B2B2B", "name": "Charcoal" }, "secondary": "auto" }, "fonts": { "heading": "Playfair Display", "body": "Inter", "library": [ { "id": "1b7e4c2d-9a3f-4e51-b6d8-0c2f7a9e3d15", "name": "Playfair Display", "type": "GOOGLE" }, { "id": "8d2a6f1c-4b9e-4a07-93c5-e7f1b2d4a6c8", "name": "Inter", "type": "GOOGLE" } ] }, "logos": [ { "id": "5e9c1a7b-2d4f-4b83-a6e0-3f8b1c5d7a29", "url": "https://storage.googleapis.com/example-bucket/logos/harbour-street-logo.png", "filename": "harbour-street-logo.png", "description": null, "isPrimary": true } ], "primaryLogoUrl": "https://storage.googleapis.com/example-bucket/logos/harbour-street-logo.png", "logoOnPosts": { "frequency": "sometimes", "placement": "auto", "logoId": null }, "styleGuide": { "styleType": "template", "moodTags": [ "warm", "homely" ], "aestheticTags": [ "natural light", "hand lettering" ], "personalityNote": "A neighbourhood bakery that talks like the person behind the counter.", "styleNote": null, "rules": [ "Show bread on wooden boards, never on white plates." ] } } ``` Errors: only the common ones. See [Errors](https://docs.native.no/native-api/errors). ## Change colours and fonts MCP tool `update_brand_style` (in development) Adds colours to the palette, sets which colour plays which role, and sets the heading and body fonts. It changes data, so the token needs the `mcp:write` scope. - Send only what changes. Everything you leave out stays as it is. A call must name at least one palette colour, role or font. - `palette` only adds colours. It never removes or renames one. A hex the palette already has keeps its own name. - A role set to a hex the palette does not have adds that hex to the palette. - A font can be any family in the brand’s font library, or any Google Fonts family. A Google family the library does not have is added to it first, under Google’s own spelling. - Roles and fonts from one call are saved together, as one new Design style version. - It replaces the colours and fonts the brand had in those roles. If your client chose them, check with them before you change them. Arguments: - `brandId` (string, required): The brand, as a UUID. - `palette` (object[], optional): Colours to add to the palette. Up to 20. - `hex` (string, required): A six-digit hex code with its `#`, such as `#6E8B74`. - `name` (string, optional): A name for the colour. Up to 100 characters. Ignored when the palette already has the hex. - `colors` (object, optional): The roles to change. Send only the roles that change. - `background` (string, optional): A `#RRGGBB` hex, or `auto`. - `accent` (string, optional): A `#RRGGBB` hex, `auto`, or `none` for no accent. - `text` (string, optional): A `#RRGGBB` hex, or `auto` for dark or light text by contrast with the background. - `secondary` (string, optional): A `#RRGGBB` hex, `auto`, or `none` for no secondary colour. - `fonts` (object, optional): The fonts to change. - `heading` (string, optional): The heading font family. 1 to 200 characters. A family in `fonts.library`, or a Google Fonts family. - `body` (string, optional): The body font family. 1 to 200 characters. A family in `fonts.library`, or a Google Fonts family. The result data is the brand’s style as it now reads, in the same shape as `get_brand_style`, with one more field: Result data, added to the style: - `updated` (object, required): What this call changed. - `colorsAdded` (string[], required): The hex codes this call added to the palette. Empty when it added none. - `fontsAdded` (string[], required): The font families this call added to the library from Google Fonts. - `designStyle` (string, required; one of: `saved`, `unchanged`, `not_requested`): `saved`: a new Design style version was saved. `unchanged`: the roles and fonts you sent were already set, so no version was saved. `not_requested`: the call set no role and no font. This call adds a sage green to the palette, makes it the secondary colour, and sets a new heading font from Google Fonts: Arguments: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "palette": [ { "hex": "#6E8B74", "name": "Sage" } ], "colors": { "secondary": "#6E8B74" }, "fonts": { "heading": "Fraunces" } } ``` Part of the result data: ```json { "updated": { "colorsAdded": [ "#6E8B74" ], "fontsAdded": [ "Fraunces" ], "designStyle": "saved" } } ``` ### Errors | Code | `retryable` | What it means | | --- | --- | --- | | `font_not_found` | `false` | A font you named is neither in the library nor a Google Fonts family. Nothing changed. `invalidFields` names `fonts.heading`, `fonts.body` or both. Use a name from `fonts.library`, or the family name as Google Fonts spells it. | | `design_style_unavailable` | `false` | The brand is not on Design style, or its Design style cannot be read, so it cannot take roles or fonts. Nothing changed. A call that sends only `palette` still works. | | `design_style_busy` | `true` | Another save landed at the same moment, or Native was still setting the Design style up. Roles and fonts were not set. Palette colours and library fonts the call added stay. Call again with the same arguments in a few seconds. | | `bad_request` | `false` | The call names no change at all, or a value is not one Native accepts. When a field is at fault, `invalidFields` names it. | If Native cannot reach Google Fonts to look up a font, the call fails with `internal_error`. The same call can succeed a little later. Of the codes in the table, only `design_style_busy` is worth sending again unchanged. A short wait between tries is enough: Retry a busy save: ```ts async function updateBrandStyle(args: Record) { for (let attempt = 1; attempt <= 3; attempt++) { const result = await client.callTool({ name: "update_brand_style", arguments: args }); const data = (result.structuredContent as { data: any }).data; if (!result.isError) return data; if (data.error.code !== "design_style_busy") throw new Error(data.error.code); await new Promise((resolve) => setTimeout(resolve, 3_000 * attempt)); } throw new Error("design_style_busy"); } const style = await updateBrandStyle({ brandId: "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", colors: { accent: "#B5532A", secondary: "none" }, }); console.log(style.updated.designStyle); ``` ## Upload a logo MCP tool `upload_brand_logo` (in development) Saves a logo for the brand. By default it also becomes the primary logo: the one Native uses in images and ads. It changes data, so the token needs the `mcp:write` scope. - Send the file one of two ways: a public https URL in `sourceUrl`, which Native fetches, or the file itself in `dataBase64` with its `contentType`. Send exactly one of them. - The logo it replaces stays among the brand’s logos. - Posts and ads already made keep the logo they were made with. - An SVG is stored as a PNG. Arguments: - `brandId` (string, required): The brand, as a UUID. - `sourceUrl` (string, optional): A public https URL of the logo file. Native fetches it. Up to 2,048 characters. Send this or `dataBase64`, not both. - `dataBase64` (string, optional): The file as standard base64, with `+`, `/` and `=` padding. Line breaks are fine. base64url is refused. Up to 11 MiB of characters, which is an image of about 8 MiB. Needs `contentType`. - `contentType` (string, optional; one of: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/svg+xml`): Required with `dataBase64`. The bytes must be the type you name. - `filename` (string, optional): A name for the file. Up to 120 characters. Native gives it the extension of the stored file. `logo` when you leave it out. - `description` (string, optional): What this logo is for, such as "For dark backgrounds". Up to 1,000 characters. - `primary` (boolean, optional; default: `true`): Make this the logo Native uses in images and ads. `false` saves an extra variant and leaves the primary logo as it is. If the brand has no primary logo yet, the new logo becomes primary even with `false`. Result data: - `brandId` (string, required): The brand. - `logo` (object, required): The saved logo, in the same shape as `logos[]` in `get_brand_style`. - `id` (string, required): The logo’s id. - `url` (string, required): A public URL of the stored file. It does not expire. - `filename` (string, required): The file name. - `description` (string | null, required): The description you sent, or `null`. - `isPrimary` (boolean, required): Whether this is now the primary logo. - `madePrimary` (boolean, required): Whether this call made the logo the primary one. - `primaryLogoUrl` (string | null, required): The logo Native now puts in images and ads. - `logoOnPosts` (object, required): How the logo goes on posts, in the same shape as `logoOnPosts` in `get_brand_style`. - `frequency` (string, required; one of: `always`, `sometimes`, `never`): How often a post carries the logo. - `placement` (string, required): Where the logo goes, such as `auto` or `bottom-right`. - `logoId` (string | null, required): The saved logo the Design style picked for posts, or `null` when posts follow the primary logo. > **A new primary logo may not reach posts.** The Design style can pick one saved logo for posts. When it does, `logoOnPosts.logoId` names that logo, and new posts keep using it after you upload a new primary logo. None of the tools on this page changes that pick, so tell your client which logo their posts will carry. Arguments: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "sourceUrl": "https://www.harbourstreetbakery.example/assets/logo-light.svg", "description": "For dark backgrounds", "primary": false } ``` Result data: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "logo": { "id": "b3d7f2a9-6c1e-4f08-8a5d-2e9c4b7f1a63", "url": "https://storage.googleapis.com/example-bucket/logos/logo.png", "filename": "logo.png", "description": "For dark backgrounds", "isPrimary": false }, "madePrimary": false, "primaryLogoUrl": "https://storage.googleapis.com/example-bucket/logos/harbour-street-logo.png", "logoOnPosts": { "frequency": "sometimes", "placement": "auto", "logoId": null } } ``` To send the file itself, read it and encode it as base64: Upload a logo from a file: ```ts import { readFile } from "node:fs/promises"; const bytes = await readFile("harbour-street-logo.png"); const result = await client.callTool({ name: "upload_brand_logo", arguments: { brandId: "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", dataBase64: bytes.toString("base64"), contentType: "image/png", filename: "harbour-street-logo.png", }, }); const data = (result.structuredContent as { data: any }).data; if (result.isError) { // "logo_unreadable" when the file is not a usable image throw new Error(data.error.code); } if (data.logoOnPosts.logoId && data.logoOnPosts.logoId !== data.logo.id) { // Posts keep the logo the Design style picked for them. } ``` ### Errors | Code | `retryable` | What it means | | --- | --- | --- | | `logo_unreadable` | `false` | The file is not a usable logo. It is not an image at least 16 px each way, it failed the upload scan, or it could not be fetched from `sourceUrl`. A download that fails, answers with something other than an image, or redirects to a private address counts as not fetched. Nothing was saved. `invalidFields` names `sourceUrl` or `dataBase64`. | | `source_not_https` | `false` | `sourceUrl` is not https. Nothing was fetched. Send the same address as https. | | `source_not_public` | `false` | `sourceUrl` itself points at a private or internal address. Nothing was fetched. Use an address anyone on the internet can reach. | | `bad_request` | `false` | `contentType` is not one of the five types, `dataBase64` is not valid base64 or is empty, the decoded file is over 8 MiB, or the bytes are not the type `contentType` names. | If Native cannot scan or store the file, the call fails with `internal_error` and nothing is saved. The same call can then succeed. ## Other errors Every tool on this page can also return the errors any tool can: `bad_request`, `not_found`, `forbidden`, `subscription_required`, `internal_error`, `upstream_timeout`, `upstream_unavailable`, `upstream_identity_failed`, `identity_provider_unavailable` and `upstream_`. `update_brand_style` and `upload_brand_logo` can return `writes_disabled`. [Errors](https://docs.native.no/native-api/errors) explains each one. --- # Social accounts Source: https://docs.native.no/native-api/social-accounts > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Send a client a link to connect a social account, handle what comes back to your app, and check that the account can publish. > **Not live for partners yet.** Partner access to the Native API is not live. Partner sign-in with client credentials and the versioned endpoint `/mcp/v1` are still in development. `create_connect_link` and `get_profile` are in Native’s MCP server today for people signed in to Native, not for partner backends. The `platform` and `returnUrl` arguments, `get_connect_options` and `complete_connect` are in development and not deployed. A brand publishes through the social accounts connected to it. Your client connects each account themselves: they log in with the platform and grant access there. You never handle their password. You start a connect with a link from `create_connect_link`, and you confirm it with `get_profile`. ## Two kinds of link | Detail | Link to Native’s connect page | Headless link | | --- | --- | --- | | Arguments | `brandId`, and optionally `returnUrl` | `brandId`, `platform` and `returnUrl` | | What the client sees | A Native page that lists every platform. They pick one and log in. | The platform’s own login and consent screen, straight away. No Native page. | | How you learn the result | Call `get_profile`. | The browser comes back to `returnUrl` with a `status`. Then confirm with `get_profile`. | | Status | In the gateway today for signed-in Native users, without `returnUrl`. | In development. | A headless link suits an app where your client never sees Native: they click "Connect Instagram" in your app, consent on Instagram, and land back in your app. ## How a headless connect works Headless links are in development. This is how they are planned to work: 1. Your backend calls `create_connect_link` with `brandId`, `platform` and `returnUrl`. 2. You send your client’s browser to the `url` it returns. Your backend never opens it. 3. The client logs in with the platform and grants access. 4. The browser comes back to `returnUrl` with `status` and `platform` added to it. 5. On `selection_required`, you list the client’s Pages or organisations with `get_connect_options`, they choose one, and you call `complete_connect`. 6. You call `get_profile` until the account’s channel is `connected` and `readiness.canPublish` is `true`. ## Create a connect link MCP tool `create_connect_link` (preview) Makes a single-use link your client opens to connect or reconnect a social account for one brand. It changes data, so the token needs the `mcp:write` scope. - Hand the `url` to your client. Do not open or fetch it yourself: loading it uses it up. - If nobody opens it, the link expires about 10 minutes after you make it. Once it is opened, the client has about 15 minutes to finish at the platform. - The link only connects an account to this brand. It gives nobody access to the brand or the workspace. - When a link expires, make a fresh one. A fresh link can always be made. Arguments: - `brandId` (string, required): The brand to connect an account to, as a UUID. - `platform` (string, optional; one of: `facebook`, `instagram`, `linkedin`, `twitter`, `tiktok`, `youtube`, `pinterest`, `reddit`, `threads`, `googlebusiness`): New, in development: makes the link headless. It skips Native’s page and opens this platform’s own login and consent screen. Requires `returnUrl`. `twitter` is X. These are lowercase channel names, as `get_profile` uses them. - `returnUrl` (string, optional): New, in development: where the browser goes when the connect is finished. An absolute https URL of 1 to 2,048 characters, with no user name or password in it and no spaces or control characters. Plain http works only for `localhost`, `127.0.0.1` and `[::1]`, and never in production. Bluesky is not on the `platform` list. It connects with an app password typed into Native’s own page, so use a link without `platform` for it. With `returnUrl` and no `platform`, the link opens Native’s connect page, and its Done button takes the client to `returnUrl`. Native adds nothing to the URL in that case. Call `get_profile` to see what was connected. A brand still on Native’s older social provider gets that provider’s own connect page instead, which has no Done button. Result data: - `url` (string, required): The link to hand to your client. A headless link has the form `https://app.native.no/e/connect//go` and sends the browser straight on to the platform. - `expiresAt` (ISO 8601 string, required): When the link expires if nobody opens it. About 10 minutes after you made it. - `singleUse` (boolean, required): Always `true`. - `brandId` (string, required): The brand. - `brandName` (string, required): The brand’s name. - `platform` (string | null, optional): New, in development: the platform a headless link opens, or `null` for a link to Native’s connect page. Absent when you sent neither `platform` nor `returnUrl`. Arguments: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "platform": "instagram", "returnUrl": "https://app.example.com/connect/done?client=4711" } ``` Result data: ```json { "url": "https://app.native.no/e/connect/q8Zt3VbN1xKc5mWp0rLs2A/go", "expiresAt": "2026-11-02T09:10:00.000Z", "singleUse": true, "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "brandName": "Harbour Street Bakery", "platform": "instagram" } ``` ### Errors | Code | `retryable` | What it means | | --- | --- | --- | | `platform_not_supported` | `false` | `platform` is not one Native can open headless. `supportedChannels` lists the ones it can. The tool’s input schema refuses other values first, so you are unlikely to see this. | | `bad_request` | `false` | `platform` was sent without `returnUrl`, or `returnUrl` is not an address Native accepts. | | `headless_connect_unavailable` | `false` | Native could not make the link you asked for with `platform` or `returnUrl`, for example because the brand is still on Native’s older social provider. You got no link. Call again without `platform` and `returnUrl` for a link to Native’s connect page. | ## What comes back to your return URL When a headless connect ends, Native sends the browser to `returnUrl` with query parameters added. `status` says how it ended, and `platform` is always there. | `status` | Other parameters | What to do | | --- | --- | --- | | `connected` | `platform` | Confirm with `get_profile`. | | `selection_required` | `platform`, `connectToken` | The client manages several Facebook Pages, LinkedIn organisations, Pinterest boards or Google Business locations, and has to pick one. Pass `connectToken` to `get_connect_options`, let the client choose, then call `complete_connect`. | | `failed` | `platform`, `error` | Nothing was connected. `error` says why. Make a fresh link to try again. | Examples: ```text https://app.example.com/connect/done?client=4711&status=connected&platform=instagram https://app.example.com/connect/done?client=4711&status=selection_required&platform=linkedin&connectToken=... https://app.example.com/connect/done?client=4711&status=failed&platform=tiktok&error=oauth_denied ``` Native first removes any `status`, `platform`, `connectToken` or `error` already on your URL, then adds its own. Your other query parameters stay. So you can put your own reference to the client in `returnUrl`, as `client=4711` does above. > **Check before you trust it.** Anyone can open your `returnUrl` with parameters they made up. Treat `status=connected` as a reason to check, and confirm with `get_profile` before you tell your client the account is connected. If your client opens a headless link after it has expired or been used, Native still sends the browser back to `returnUrl`, with `status=failed` and `error=link_expired`. It does this for up to an hour after you made the link. After that, the link opens Native’s own expired-link page. ### Failure codes | `error` | What happened | | --- | --- | | `oauth_denied` | The client declined or cancelled on the platform’s screen. | | `link_expired` | The link had expired or been used before the client got through. | | `account_in_another_workspace` | The account is already connected to a profile in another Native workspace, so it was not added here. Instagram, TikTok and X connect whichever account the browser is logged in to, so the client may need to log out there and log in to the right account first. | | `no_facebook_pages` | Facebook gave access to no Pages. The client should connect again and select their Pages on Facebook’s screen. | | `no_google_locations` | The Google account manages no Google Business location. | | `missing_google_permissions` | Google did not grant every permission Native asked for. The client should connect again and tick every box on Google’s screen. | | `connection_failed` | Anything else, at the platform or at Native. | Treat `error` as an open set. Some codes come from Native’s social provider, and new ones can appear. Whatever the code, a fresh link is the way to try again. ## List what the client can connect MCP tool `get_connect_options` (in development) After `status=selection_required`, lists the Pages, organisations, boards or locations the client can connect to the brand. Show them to the client and let them choose. Reading the options changes nothing, and you can call it again. Arguments: - `brandId` (string, required): The brand, as a UUID. - `connectToken` (string, required): The `connectToken` query parameter from `returnUrl`, exactly as it came. 1 to 8,192 characters. It lasts about 10 minutes, and only the caller that made the link can use it. Result data: - `brandId` (string, required): The brand. - `platform` (string, required): The platform, such as `linkedin`. - `options` (object[], required): What the client can choose from. - `id` (string, required): Pass the chosen one to `complete_connect` as `selectionId`. - `name` (string, required): The name to show. - `kind` (string, required; one of: `page`, `organization`, `personal`, `board`, `location`): `page`: a Facebook Page. `organization`: a LinkedIn organisation. `personal`: the client’s own LinkedIn profile. `board`: a Pinterest board. `location`: a Google Business location. - `pictureUrl` (string | null, required): A picture to show beside the name, when the platform gives one. - `detail` (string | null, required): A Page’s category or a location’s address. `null` for the others. For LinkedIn, the client’s own profile comes first, with `kind: "personal"` and `id: "__personal__"`. Result data: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "platform": "linkedin", "options": [ { "id": "__personal__", "name": "Kari Nordmann", "kind": "personal", "pictureUrl": null, "detail": null }, { "id": "urn:li:organization:81234567", "name": "Harbour Street Bakery", "kind": "organization", "pictureUrl": null, "detail": null } ] } ``` ### Errors | Code | `retryable` | What it means | | --- | --- | --- | | `connect_session_expired` | `false` | The `connectToken` has expired, or its link has died. Nothing was connected. Make a fresh link with `create_connect_link` and have the client go through the platform again. | ## Finish the connect MCP tool `complete_connect` (in development) Connects the option your client chose to the brand. It changes data, so the token needs the `mcp:write` scope. It replaces any account the brand already had on that platform. Only call it with your client’s own choice. Arguments: - `brandId` (string, required): The brand, as a UUID. - `connectToken` (string, required): The same `connectToken` you passed to `get_connect_options`. 1 to 8,192 characters. - `selectionId` (string, required): The `id` of the option your client chose, from `get_connect_options`. 1 to 200 characters. `__personal__` connects a LinkedIn member’s own profile. Result data: - `brandId` (string, required): The brand. - `platform` (string, required): The platform. - `connected` (boolean, required): Always `true`. - `account` (object, required): The account that is now connected. - `displayName` (string | null, required): The account’s name, when the platform gives one. - `username` (string | null, required): The account’s handle, when it has one. Arguments: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "connectToken": "c2VhbGVkLWNvbm5lY3QtdG9rZW4tZXhhbXBsZQ", "selectionId": "urn:li:organization:81234567" } ``` Result data: ```json { "brandId": "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63", "platform": "linkedin", "connected": true, "account": { "displayName": "Harbour Street Bakery", "username": null } } ``` ### Errors | Code | `retryable` | What it means | | --- | --- | --- | | `connect_session_expired` | `false` | The `connectToken` has expired, or its link has died. Nothing was connected. Make a fresh link with `create_connect_link`. | | `bad_request` | `false` | `selectionId` is not one of the options `get_connect_options` returned. `invalidFields` names `selectionId`. | ## Confirm the connection MCP tool `get_profile` (preview) `get_profile` shows what a brand can do right now, including each connected account and its health. After `status=connected`, or after `complete_connect`, call it once. If the account is not ready yet, call it every 10 seconds for up to 2 minutes. The account is ready when its channel has `health: "connected"` and `readiness.canPublish` is `true`. A `failed` status needs a fresh link, not more polling. Arguments: - `brandId` (string, required): The brand. - `includeScopes` (boolean, optional; default: `false`): Also fetch the permissions each account granted. It costs one extra call to Native’s social provider per connected account, so leave it off for routine checks. The result data has more than this, such as `brandName` and `generationPlatforms`. [Profiles](https://docs.native.no/native-api/profiles#get-a-profile) has the full reference. These are the fields that tell you whether a connect worked: Result data, the connection fields: - `readiness` (object, required): Whether the brand can make and publish posts. - `canGenerate` (boolean, required): Whether Native can make posts for the brand. - `canPublish` (boolean, required): Whether the brand has an account it can publish to. - `blockers` (string[], required): What stands in the way, as codes. Treat the list as open. `no_channel_connected` and `all_channels_need_reauth` both mean: send your client a connect link. `connection_check_failed` means Native could not check the accounts: call again later, and do not send a connect link. New, in development: `profile_not_ready` while Native is still building a profile from a website. [Readiness blockers](https://docs.native.no/native-api/profiles#readiness-blockers) lists every code. - `channels` (object[], required): One entry per connected account, including accounts that need reconnecting and accounts that were disconnected. - `platform` (string, required): The lowercase channel name, such as `instagram`. - `displayName` (string | null, required): The account’s name. - `username` (string | null, required): The account’s handle. - `profileUrl` (string | null, required): A link to the account on the platform. - `health` (string, required; one of: `connected`, `expiring_soon`, `needs_reauth`, `disconnected`): `connected` and `expiring_soon` publish. `needs_reauth` and `disconnected` do not. - `healthReason` (string | null, required): A short reason for the health, when there is one. - `connectedAt` (ISO 8601 string | null, required): When the account was connected. - `tokenRefreshInDays` (number | null, required): Days until the platform’s access needs renewing. `null` when the platform does not report it. - `tokenExpiresAt` (ISO 8601 string | null, optional): When the platform’s access runs out. May be absent. - `scopes` (string[] | null, required): The permissions the account granted. Filled only with `includeScopes`. `null` means unknown, not none. - `missingScopes` (string[] | null, optional): Permissions Native needs that the account did not grant. May be absent. - `canPost` (boolean | null, optional): Whether Native can post to the account. May be absent. - `canFetchAnalytics` (boolean | null, optional): Whether Native can read the account’s analytics. May be absent. - `storyDestinations` (string[] | null, optional): The story destinations the brand can use, such as `instagram_stories` and `facebook_stories`. May be absent. Part of the result data: ```json { "readiness": { "canGenerate": true, "canPublish": true, "blockers": [] }, "channels": [ { "platform": "instagram", "displayName": "Harbour Street Bakery", "username": "harbourstreetbakery", "profileUrl": "https://www.instagram.com/harbourstreetbakery", "health": "connected", "healthReason": null, "connectedAt": "2026-11-02T09:04:12.000Z", "tokenRefreshInDays": 58, "scopes": null } ], "storyDestinations": [ "instagram_stories" ] } ``` Stories have no connect link of their own. Instagram and Facebook stories publish through the Instagram and Facebook accounts, and never appear as channels. Channel names in `get_profile` are lowercase, such as `instagram`. A post’s destinations use uppercase names, such as `INSTAGRAM`. ## A full headless connect This sketch uses `client`, an official MCP SDK client connected with a partner token, as in [Calling a tool](https://docs.native.no/native-api#calling-a-tool). Make the link and send your client’s browser to it: Make the link: ```ts const brandId = "3f6c2a9e-8b1d-4c57-9e0a-2d4b7f1c8a63"; const minted = await client.callTool({ name: "create_connect_link", arguments: { brandId, platform: "linkedin", returnUrl: "https://app.example.com/connect/done?client=4711", }, }); const link = (minted.structuredContent as { data: any }).data; if (minted.isError) throw new Error(link.error.code); // Redirect your client's browser to link.url. Never fetch it from your backend. ``` When the browser comes back, read `status` from the URL: Handle the return: ```ts async function onConnectReturn(returned: URL, brandId: string) { const status = returned.searchParams.get("status"); if (status === "failed") { // Offer a fresh link. The error parameter says why it failed. return { state: "failed", error: returned.searchParams.get("error") }; } if (status === "selection_required") { const connectToken = returned.searchParams.get("connectToken") ?? ""; const listed = await client.callTool({ name: "get_connect_options", arguments: { brandId, connectToken }, }); const data = (listed.structuredContent as { data: any }).data; if (listed.isError) return { state: "failed", error: data.error.code }; // Show data.options to your client. Keep connectToken until they choose. return { state: "choose", options: data.options, connectToken }; } // "connected", or anything else: check the profile before you trust it. return { state: "check" }; } ``` Once your client has chosen, finish the connect. Then wait until the account can publish: Finish and confirm: ```ts async function finishConnect(brandId: string, connectToken: string, selectionId: string) { const done = await client.callTool( { name: "complete_connect", arguments: { brandId, connectToken, selectionId }, }, undefined, { timeout: 150_000 }, ); const data = (done.structuredContent as { data: any }).data; // connect_session_expired: make a fresh link and start again. if (done.isError) throw new Error(data.error.code); } async function waitUntilPublishable(brandId: string, platform: string) { for (let attempt = 0; attempt < 12; attempt++) { const result = await client.callTool({ name: "get_profile", arguments: { brandId } }); const profile = (result.structuredContent as { data: any }).data; if (result.isError) throw new Error(profile.error.code); const channel = profile.channels.find((c: { platform: string }) => c.platform === platform); if (channel?.health === "connected" && profile.readiness.canPublish) return true; await new Promise((resolve) => setTimeout(resolve, 10_000)); } return false; // Not ready after 2 minutes: offer a fresh link. } ``` ## Reconnecting an account Platforms take access back after a while, and people change passwords. Check channel health with `get_profile` once a day, and before you schedule. | `health` | Publishes | What to do | | --- | --- | --- | | `connected` | Yes | Nothing. | | `expiring_soon` | Yes | Send your client a link for that platform before `tokenExpiresAt`, so publishing does not stop. | | `needs_reauth` | No | Send your client a link for that platform. They log in with the same account. | | `disconnected` | No | Send your client a link for that platform. | A reconnect uses the same `create_connect_link` call as a first connect. Tell your client which account to reconnect: `get_profile` names it. ## Other errors Every tool on this page can also return the errors any tool can: `bad_request`, `not_found`, `forbidden`, `subscription_required`, `internal_error`, `upstream_timeout`, `upstream_unavailable`, `upstream_identity_failed`, `identity_provider_unavailable` and `upstream_`. `create_connect_link` and `complete_connect` can return `writes_disabled`. [Errors](https://docs.native.no/native-api/errors) explains each one. --- # Generating posts Source: https://docs.native.no/native-api/generating-posts > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Write up to 20 draft posts from one brief, follow the batch until it settles, and read each post exactly as it would publish. > **Not open to partners yet.** Partners cannot call the Native API yet. Partner sign-in with client credentials is still in development. Tools marked **Preview** run in Native’s gateway today, for people signed in to Native. Anything marked **In development** is not deployed yet. Generation is always asynchronous. `create_posts` takes one brief and answers at once with a `batchId`. Native writes the posts in the background, each from a different angle and with copy for each destination. You poll the batch until it is done, then show each post to your client. ## How it works 1. Start a batch with `create_posts` and store the `batchId`. 2. Poll `get_post_batch_status` until `settled` is `true` and `imagesPendingCount` is `0`. 3. Read each created post. Once the item fields in development ship, each created item carries its destinations, pictures, `contentDigest` and `approval`. Until then, call `view_post` for each `postId`. 4. Show the reviewer what you read. Then call `approve_post` with that read’s `contentDigest`, or `reject_post` with a reason. See [Review and approval](https://docs.native.no/native-api/approval). 5. To change a post before review, see [Editing posts](https://docs.native.no/native-api/editing-posts). A post is a draft until you schedule or publish it. Nothing on this page sends anything to a social platform. ## Start a batch MCP tool `create_posts` (preview) Creates 1 to 20 draft posts from one brief, as one batch. Each post takes a different angle on the brief and gets copy written for each of its destinations. The call returns before any post exists: you get a `batchId` and one pending slot per post. Input: - `brandId` (string, required): The client’s brand. - `brief` (string, required): What the posts are about, in plain words. 1 to 4,000 characters. - `count` (integer, required): How many posts to write, from 1 to 20. - `mediaIds` (string[], optional): Up to 10 brand library image ids. Every post in the batch gets the same pictures. Cannot be combined with `includeImage: true`. - `platforms` (string[], optional): Destinations to add to every post in the batch, as uppercase names such as `INSTAGRAM`. Each post also keeps the brand’s default destination, when the brand has one. Leave it out and the brand’s defaults apply. Each item’s `platforms` in `get_post_batch_status` shows where a post ended up. - `scheduledAt` (ISO 8601 string, optional): An intended publishing time, recorded on each post. It does not schedule anything. A time with a numeric offset is converted to UTC. - `includeImage` (boolean, optional): Render a picture for each post. On by default when you send no `mediaIds`. `true` together with `mediaIds` is refused, because the render would replace the pictures you sent. Output: - `batchId` (string, required): Pass it to `get_post_batch_status`. - `brandId` (string, required): The brand the batch is for. - `companyId` (string, required): The client’s workspace. - `status` (string, required): `queued` when the batch is created. - `requestedCount` (integer, required): The `count` you asked for. - `includeImage` (boolean, optional): Whether Native will render a picture for each post, once the default is applied. May be absent. - `items` (object[], required): One slot per requested post. - `index` (integer, required): The slot’s position, from 0. - `status` (string, required): `pending` at first. - `connectionWarning` (string, optional): Present only when the brand has no connected channel. The posts are written with provisional destinations, and none of them can be scheduled until the client connects an account. The text is written for people. The examples on this page use `callNative` from [End-to-end flow](https://docs.native.no/native-api/flow#connect-to-the-gateway). It returns the tool’s payload, or throws a `NativeError` that carries the error’s `code` and `retryable`. Start a batch: ```ts // callNative and NativeError are defined in /docs/native-api/flow. // callNative waits up to 150 s, longer than the gateway's 120 s. const batch = await callNative('create_posts', { brandId: '8f0c2b7e-1d3a-4c5f-9e8b-7a6d5c4b3a21', brief: 'Our autumn opening hours start on Monday. We open at 7 on weekdays.', count: 2, platforms: ['INSTAGRAM', 'FACEBOOK'], }) // Store this before anything else: no tool finds a batch without it. const batchId: string = batch.batchId ``` Response: ```json { "batchId": "6b1f3c2a-8d4e-4f7a-9c1b-2e5d7a9f0c34", "brandId": "8f0c2b7e-1d3a-4c5f-9e8b-7a6d5c4b3a21", "companyId": "c2a7e9d1-5b3f-4e8a-a1c6-9d0b2f4e7a58", "status": "queued", "requestedCount": 2, "includeImage": true, "items": [ { "index": 0, "status": "pending" }, { "index": 1, "status": "pending" } ] } ``` ### Errors These refusals happen before anything is generated, so no batch was created. A post that fails later is reported on its own item in `get_post_batch_status`. | Code | `retryable` | What to do | | --- | --- | --- | | `bad_request` | `false` | Fix what `invalidFields` and `fieldErrors` name, then send the call again. | | `platform_not_connected` | `false` | A destination you named has no live connection. `unavailablePlatforms` lists those destinations and `connectedPlatforms` what is connected. Drop them, or have the client connect the account. | | `platform_not_supported` | `false` | Native does not publish to a destination you named. `unsupportedPlatforms` lists it and `supportedPlatforms` lists what you can use. | | `reddit_options_required` | `false` | A `REDDIT` destination needs Reddit options, and `create_posts` takes none. Leave `REDDIT` out of the batch. Add it to a post afterwards with `update_post` and its `reddit` options. | | `media_required` | `false` | Every destination is a story, and you sent no `mediaIds`. A picture Native would render does not count. Send `mediaIds`, or add a feed destination. | | `usage_limit_exceeded` | `false` | The workspace’s AI allowance is used up. `resetDate` says when it refills. | | `ai_generation_failed` | `true` | Generation failed before the batch started. Send the call again. | | `profile_not_ready` | `true` | New, in development: the profile `create_profile` started is not ready. Poll `get_profile` until `onboarding.status` is `ready`. If it is `failed`, first retry the build with `create_profile` and the same `externalId`. See [Profiles](https://docs.native.no/native-api/profiles#wait-for-the-build). | Any tool can also return the common errors, such as `not_found`, `subscription_required` or `upstream_timeout`. [Errors](https://docs.native.no/native-api/errors) lists every code. ### Resending a batch A resent `create_posts` call is a new batch. The gateway does not deduplicate it, and you cannot send your own idempotency key yet. If a call times out or the connection drops, Native may still have started the batch, and a second call can give your client two sets of drafts. No tool finds a batch without its `batchId`, so store it the moment you have it. ## Follow the batch MCP tool `get_post_batch_status` (preview) Reads a batch and changes nothing, so you can call it as often as the polling guidance below allows. Input: - `batchId` (string, required): From `create_posts`. Output: - `batchId` (string, required): The batch. - `brandId` (string, required): The brand the batch is for. - `companyId` (string, required): The client’s workspace. - `status` (string, required; one of: `queued`, `generating`, `partial`, `ready`, `failed`): Where the batch is. Branch on `settled`, not on this. - `settled` (boolean, required): Copy generation has finished: `true` once `status` is `ready`, `partial` or `failed`. It does not mean every post was created, or that every picture has finished. - `requestedCount` (integer, required): The `count` you asked for. - `createdCount` (integer, required): Slots that became posts. - `failedCount` (integer, required): Slots that ended without a post. Counts both `failed` and `skipped` items. - `pendingCount` (integer, required): Slots still `pending` or `generating`. - `imagesPendingCount` (integer, optional): Created posts whose picture is still rendering. May be absent. - `warnings` (string[], optional): Notes on the batch as a whole, such as posts with similar copy or a destination that needs media. They are written for people, so show them rather than branch on them. May be absent. - `error` (string | null, optional): Why the batch failed, when it failed as a whole. May be absent. - `items` (object[], required): One per slot, in order. - `index` (integer, required): The slot’s position, from 0. - `status` (string, required; one of: `pending`, `generating`, `created`, `failed`, `skipped`): `skipped` means the batch stopped before this slot, for example because the workspace’s AI allowance ran out. `error` says why. - `postId` (string | null, required): Set once the slot is `created`. - `title` (string | null, required): The post’s title. - `platforms` (string[], required): The post’s destinations. - `preview` (string | null, optional): How the post opens, so you can check that the posts differ. May be absent. - `hasMedia` (boolean, optional): The post has at least one picture or video. May be absent. - `imagePending` (boolean, optional): The post exists and its copy is done, but its picture is still rendering. May be absent. - `mediaRequiredFor` (string[], optional): Destinations that cannot publish this post until it has media, such as a story. May be absent. - `error` (object | null, required): Why the slot did not become a post. `null` otherwise. - `code` (string, required; one of: `usage_limit_exceeded`, `ai_generation_failed`, `platform_not_connected`, `platform_not_supported`, `reddit_options_required`, `media_required`, `bad_request`, `internal_error`): Branch on this. Treat a code you do not know by its `retryable` flag. - `message` (string, required): Written by Native for people. It can change at any time. - `retryable` (boolean, required): Whether the same request, unchanged, could succeed. - `appUrl` (string, optional): The post in the Native app. Only on created slots. - `destinations` (object[], optional): New, in development: each destination exactly as `view_post` returns it. See [the destination object](https://docs.native.no/native-api/generating-posts#destination-object). Empty on a slot that holds no post. - `imageUrls` (string[], optional): New, in development: every slide, in order. Empty on a slot that holds no post. - `contentDigest` (string | null, optional): New, in development: the same value `view_post` returns. It changes when a pending picture lands, so approve only once `imagePending` is `false`. `null` on a slot that holds no post. - `approval` (object | null, optional): New, in development: the latest review decision, shaped like [the approval object](https://docs.native.no/native-api/approval#approval-object). `null` when there is none. - `createdAt` (ISO 8601 string, optional): When the batch was created. May be absent. - `updatedAt` (ISO 8601 string, optional): When the batch last changed. May be absent. - `appOrigin` (string, required): The Native app’s address, such as `https://app.native.no`. > **Until the item fields ship.** Today a created item has no `destinations`, `imageUrls`, `contentDigest` or `approval`. Call `view_post` for each `postId` to show the post and approve it. Once the fields ship, they are on every created slot. ### Item errors A slot that failed or was skipped carries an `error` with its own `retryable` flag. No tool retries a single slot. To make up for failed slots, start a new batch for the number you are missing. | Code | What it means | | --- | --- | | `usage_limit_exceeded` | The workspace’s AI allowance ran out during the batch. | | `ai_generation_failed` | Writing this post failed. | | `platform_not_connected` | A destination for this post has no live connection. | | `platform_not_supported` | Native does not publish to a destination for this post. | | `reddit_options_required` | A `REDDIT` destination had no Reddit options. | | `media_required` | Every destination is a story, and the post has no media. | | `bad_request` | Native refused the post as invalid. | | `internal_error` | Something failed inside Native. | ### Polling - Poll every 5 seconds for the first minute, then every 15 seconds, backing off to at most 30 seconds. - Stop when `settled` is `true` and `imagesPendingCount` is `0`. `settled` covers copy only: pictures can still be rendering after it. - Give up and alert someone after about 15 minutes. - On an error with `retryable: true`, back off with jitter and poll again. Stop on any other error. A batch’s pictures are followed through `imagePending` and `imagesPendingCount`. They have no job id. `get_generation_job` follows a render you start on a single post, and is described under [Editing posts](https://docs.native.no/native-api/editing-posts#get-generation-job). Wait for the batch: ```ts const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function waitForBatch(batchId: string) { const startedAt = Date.now() let delay = 5_000 for (;;) { try { const status = await callNative('get_post_batch_status', { batchId }) if (status.settled && (status.imagesPendingCount ?? 0) === 0) return status } catch (err) { // Poll again only when Native says the same call could succeed. if (!(err instanceof NativeError) || !err.retryable) throw err } const elapsed = Date.now() - startedAt if (elapsed > 15 * 60_000) throw new Error('Batch did not finish within 15 minutes') // 5 seconds for the first minute, then 15, growing to no more than 30. if (elapsed > 60_000) delay = Math.min(30_000, Math.max(15_000, delay * 1.5)) await sleep(delay + Math.random() * 1_000) } } const done = await waitForBatch(batchId) for (const item of done.items) { if (item.status !== 'created') { console.warn('Slot ' + item.index + ' ended as ' + item.status + ': ' + item.error?.code) continue } // Show item.destinations to the reviewer and keep item.contentDigest // for approve_post. Until those fields ship, call view_post with item.postId. } ``` Response, with the item fields in development: ```json { "batchId": "6b1f3c2a-8d4e-4f7a-9c1b-2e5d7a9f0c34", "brandId": "8f0c2b7e-1d3a-4c5f-9e8b-7a6d5c4b3a21", "companyId": "c2a7e9d1-5b3f-4e8a-a1c6-9d0b2f4e7a58", "status": "partial", "settled": true, "requestedCount": 2, "createdCount": 1, "failedCount": 1, "pendingCount": 0, "imagesPendingCount": 0, "warnings": [], "error": null, "items": [ { "index": 0, "status": "created", "postId": "3d8e1a52-7c4b-4f19-b2a6-5e0d9c8f7a13", "title": "Autumn opening hours", "platforms": ["INSTAGRAM", "FACEBOOK"], "preview": "Autumn hours start on Monday.", "hasMedia": true, "imagePending": false, "mediaRequiredFor": [], "error": null, "appUrl": "https://app.native.no/api/open-post?id=3d8e1a52-7c4b-4f19-b2a6-5e0d9c8f7a13", "destinations": [ { "platform": "INSTAGRAM", "text": "Autumn hours start on Monday. We open at 7 on weekdays, so the first cinnamon buns are out before your train.", "firstComment": null, "title": null, "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png"], "images": [ { "url": "https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png", "slideText": "Autumn hours from Monday", "slideTextSource": "layers", "altText": "A tray of cinnamon buns on the counter by the window" } ], "thumbnailUrl": null, "characterCount": 109, "characterLimit": 2200, "publishingOptions": null }, { "platform": "FACEBOOK", "text": "Autumn hours start on Monday. We open at 7 on weekdays, so the first cinnamon buns are out before your train.", "firstComment": null, "title": null, "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png"], "images": [ { "url": "https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png", "slideText": "Autumn hours from Monday", "slideTextSource": "layers", "altText": "A tray of cinnamon buns on the counter by the window" } ], "thumbnailUrl": null, "characterCount": 109, "characterLimit": 63206, "publishingOptions": null } ], "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png"], "contentDigest": "v3.ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "approval": null }, { "index": 1, "status": "failed", "postId": null, "title": null, "platforms": [], "preview": null, "hasMedia": false, "imagePending": false, "mediaRequiredFor": [], "error": { "code": "ai_generation_failed", "message": "AI generation failed for this post.", "retryable": true }, "destinations": [], "imageUrls": [], "contentDigest": null, "approval": null } ], "createdAt": "2026-10-12T08:00:03.000Z", "updatedAt": "2026-10-12T08:02:41.000Z", "appOrigin": "https://app.native.no" } ``` ## Read one post MCP tool `view_post` (preview) Reads one post exactly as it would publish: every destination with its own caption, first comment, title, pictures and options. This is what a reviewer should see before approving. Input: - `id` (string, required): The post id. Output: - `id` (string, required): The post id. - `postId` (string, required): The same value as `id`. - `authorName` (string, required): The name the post appears under. - `authorSubtitle` (string | null, required): A short line under the name, such as the post’s status and time. - `authorAvatarUrl` (string | null, required): The picture beside the name. - `postText` (string, required): The post’s main caption as plain text, or the first destination’s caption when the post has no main caption. Read `destinations` for each destination’s own caption. - `imageUrl` (string | null, required): The first picture. - `imageUrls` (string[], required): Every slide, in order. - `destinations` (object[], required): One per destination. See [the destination object](https://docs.native.no/native-api/generating-posts#destination-object). - `contentDigest` (string | null, required): The version of the content an approval locks. See [Content digest](https://docs.native.no/native-api/generating-posts#content-digest). - `approval` (object | null, required): The latest review decision, or `null`. See [the approval object](https://docs.native.no/native-api/approval#approval-object). - `appOrigin` (string, required): The Native app’s address. - `appUrl` (string, required): The post in the Native app. Errors: only the common ones, such as `not_found` when no post has that id, and `forbidden` for a post in a workspace you cannot reach. ### The destination object Each entry is one place the post publishes. These are the fields the content digest covers, except the subtitle files sent with videos, which the API does not return. Fields: - `platform` (string, required): The destination, as an uppercase name such as `INSTAGRAM`. - `text` (string, required): This destination’s caption, as plain text. - `firstComment` (string | null, required): The first comment. - `title` (string | null, required): The publishable title, for a LinkedIn document, a TikTok slideshow or a YouTube video. - `imageUrls` (string[], required): This destination’s pictures, in order. - `images` (object[], optional): New, in development: one entry per `imageUrls` entry, in the same order. See [Slide text and alt text](https://docs.native.no/native-api/generating-posts#slide-text-and-alt-text). - `url` (string, required): The picture. - `slideText` (string | null, required): The text drawn on the slide, as data. `null` when Native has no record of it. - `slideTextSource` (string | null, required; one of: `layers`, `prompt`): Where `slideText` comes from, which says how exact it is. `null` with `slideText`. - `altText` (string | null, required): The alt text written for the picture. `null` when none was written. - `thumbnailUrl` (string | null, required): The video cover. - `characterCount` (integer, required): The length of `text`. - `characterLimit` (integer, required): This destination’s caption limit. `0` means no limit is on record. A caption over its limit is refused when you schedule. - `publishingOptions` (object | null, required): Options for this platform, such as a Reddit subreddit and flair or a TikTok post’s visibility. The keys differ per platform. Response, with the image fields in development: ```json { "id": "3d8e1a52-7c4b-4f19-b2a6-5e0d9c8f7a13", "postId": "3d8e1a52-7c4b-4f19-b2a6-5e0d9c8f7a13", "authorName": "Solsiden Bakery", "authorSubtitle": "Draft", "authorAvatarUrl": "https://storage.googleapis.com/example-bucket/logos/solsiden.png", "postText": "Autumn hours start on Monday. We open at 7 on weekdays, so the first cinnamon buns are out before your train.", "imageUrl": "https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png", "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png"], "destinations": [ { "platform": "INSTAGRAM", "text": "Autumn hours start on Monday. We open at 7 on weekdays, so the first cinnamon buns are out before your train.", "firstComment": null, "title": null, "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png"], "images": [ { "url": "https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png", "slideText": "Autumn hours from Monday", "slideTextSource": "layers", "altText": "A tray of cinnamon buns on the counter by the window" } ], "thumbnailUrl": null, "characterCount": 109, "characterLimit": 2200, "publishingOptions": null }, { "platform": "FACEBOOK", "text": "Autumn hours start on Monday. We open at 7 on weekdays, so the first cinnamon buns are out before your train.", "firstComment": null, "title": null, "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png"], "images": [ { "url": "https://storage.googleapis.com/example-bucket/posts/3d8e/slide-1.png", "slideText": "Autumn hours from Monday", "slideTextSource": "layers", "altText": "A tray of cinnamon buns on the counter by the window" } ], "thumbnailUrl": null, "characterCount": 109, "characterLimit": 63206, "publishingOptions": null } ], "contentDigest": "v3.ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "approval": null, "appOrigin": "https://app.native.no", "appUrl": "https://app.native.no/api/open-post?id=3d8e1a52-7c4b-4f19-b2a6-5e0d9c8f7a13" } ``` ## Content digest and approval `view_post` returns a `contentDigest` and an `approval`. A created batch item will carry the same two fields once the item fields in development ship. `contentDigest` is a stamp of everything that would publish, and `approve_post` records the one the reviewer saw. `approval` is the latest review decision, or `null`. [Review and approval](https://docs.native.no/native-api/approval#content-digest) explains what the digest covers and how Native checks it before a post goes out. Three things matter for posts that were just generated: - A picture that lands changes the digest. Approve a post only once its batch item’s `imagePending` is `false`. - Approve the digest from the read the reviewer saw: `view_post` today, or a created batch item once those fields ship. If the post changed in between, `approve_post` is refused with `post_content_changed`. Read it again and have it reviewed again. - Never resend an approval with a newer digest the reviewer did not see. ## Slide text and alt text > **In development.** The `images` list on each destination is in development. Nothing in this section is returned today. `slideText` is the text drawn on a slide, as data. Use it to run your own checks on what a picture says, without OCR. `slideTextSource` says how exact it is. | `slideTextSource` | What `slideText` is | | --- | --- | | `layers` | Read back off the design the picture was rendered from, so it is exactly the text on the picture. Words inside a placed photo are pixels, so they are not included. | | `prompt` | What the image model was asked to draw. It usually reproduces it exactly, but not always. | | `null` | Native has no record of the text, for example on an uploaded photo. `slideText` is `null` too. | `altText` is the alt text written for a picture. Native sends it with the picture on platforms that take alt text. It is `null` when none was written, and also while Native’s backend is older than the change that adds it. The digest covers alt text whenever any picture on a destination has some, so a change to it makes an approval stale. Show alt text to the reviewer along with the pictures. A video has no `images` entry, so alt text on a video is digested but not returned. The Native app clears alt text on videos, so this should not come up in practice. ## Pictures Picture URLs are public Google Cloud Storage addresses. They are unsigned and do not expire, so you can show them directly or copy them. Replacing or removing a picture on a post does not guarantee that its old URL stops working, so do not count on an edit to revoke access. A changed picture always has a new URL, so the post gets a new `contentDigest`. [Limits](https://docs.native.no/native-api/limits#asset-urls) has the details. ### Feed and story posts - Every destination on a post shares the same picture in each slot. - Story destinations (`INSTAGRAM_STORIES`, `FACEBOOK_STORIES`) target 9:16. Feed destinations target 4:5. - A 9:16 picture fails the Instagram feed check when you schedule. Put feed and story content in separate batches, rather than mixing `INSTAGRAM` and `INSTAGRAM_STORIES` on one post. - Each post also gets the brand’s default destination, so a batch you meant for stories can still carry a feed destination. Check each item’s `platforms`, and remove a feed destination with `update_post` and `removePlatforms` when a post should be stories only. - Stories publish without a caption, and need a single picture or video. - Not confirmed yet: whether the first render of a stories-only post from `create_posts` comes out 9:16. Check the size of the picture `view_post` returns until this is confirmed. See [aspect ratios](https://docs.native.no/native-api/limits#aspect-ratios). --- # Editing posts Source: https://docs.native.no/native-api/editing-posts > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Change a post’s caption, first comment, destinations or a single picture, and follow a picture render until it lands. > **Not open to partners yet.** Partners cannot call the Native API yet. Partner sign-in with client credentials is still in development. Tools marked **Preview** run in Native’s gateway today, for people signed in to Native. Anything marked **In development** is not deployed yet. You can change a draft or a scheduled post until it is published. Edits overwrite what is there. Native keeps no revision history of copy, but when `update_post` replaces copy, its result tells you what was there before. ## Rules for every edit - A post that is already published is refused with `post_already_published`. `publishedPlatforms` lists the destinations that are live. - Any content change gives the post a new `contentDigest` and makes an earlier approval stale. Read the post again, have it reviewed again and approve it again before you schedule. See [what the digest covers](https://docs.native.no/native-api/approval#content-digest). - An edit does not clear a rejection. Only a new `approve_post` does. - A publishing provider that holds a post for a scheduled time freezes its media. Picture edits are refused with `post_already_scheduled` until you withdraw every held destination with `unschedule_platform`. See [withdraw a destination](https://docs.native.no/native-api/publishing#withdraw-a-destination). - `generate_caption` and `regenerate_post_image` spend the workspace’s AI allowance. When it runs out they fail with `usage_limit_exceeded`, and `resetDate` says when it refills. - After a timeout or a dropped connection, read the post with `view_post` before you retry. A lost response does not prove that nothing changed. - Destinations are uppercase names, such as `INSTAGRAM`. ## Change copy, time or destinations MCP tool `update_post` (preview) Overwrites the caption, the first comment, the intended time or the set of destinations. Send at least one of `text`, `firstComment`, `scheduledAt`, `addPlatforms`, `removePlatforms` or `reddit`. Input: - `id` (string, required): The post id. - `text` (string, optional): The new caption, as plain text. It goes to every destination unless `platform` is set. Shared copy has to fit the shortest destination’s limit: a destination it is too long for keeps its old caption, and `captionResults` says so. - `firstComment` (string | null, optional): The new first comment. `null` clears it. It goes to every destination unless `platform` is set. - `platform` (string, optional): Write `text` and `firstComment` to one existing destination only, such as `LINKEDIN`, or to `all`. It never adds, removes or picks out a destination. Sent without `text` or `firstComment`, it is refused, with two exceptions: the value `all`, and a call that also sends `addPlatforms` or `removePlatforms`. In both cases it is ignored. - `scheduledAt` (ISO 8601 string | null, optional): On a draft, an intended time only: it does not schedule anything. `null` clears it. A time a publishing provider holds cannot be cleared this way. To book or move a post, use [`schedule_post`](https://docs.native.no/native-api/publishing#schedule-a-post). - `addPlatforms` (string[], optional): Destinations to add, at least one. They take the post’s copy and media. Adding `REDDIT` needs `reddit`. On a scheduled post, an added destination is not scheduled by this call. - `removePlatforms` (string[], optional): Destinations to remove, at least one. Must not overlap `addPlatforms`. The destinations you do not name keep their own captions and schedule. Removing every destination is refused. - `reddit` (object, optional): Reddit options. Required when `addPlatforms` includes `REDDIT`. Sent on its own, it rewrites the options of an existing Reddit destination. - `subreddit` (string, required): 1 to 100 characters. - `title` (string, optional): The Reddit post title. Up to 300 characters. Defaults to the caption’s first line. - `flairId` (string, optional): Up to 200 characters. The rules about which fields go together are checked before the tool runs. Breaking one gives you an MCP SDK validation error, not the error envelope. Treat that as a bug in your call. ### What the result says The result is the post as it now reads, with captions as plain text, plus what the call changed. It also carries `view_post`’s preview fields: `authorName`, `authorSubtitle`, `authorAvatarUrl`, `postText`, `imageUrl`, `imageUrls`, `destinations`, `appOrigin` and `appUrl`. Each of those may be absent if reading the post back failed. The rest of the post comes through as Native sends it, so ignore fields you do not use. Output: - `postId` (string, required): The post id. - `contentDigest` (string | null, optional): The post’s digest after the edit. It comes through with the post rather than being declared in the output schema. - `approval` (object | null, optional): The latest review decision, shaped like [the approval object](https://docs.native.no/native-api/approval#approval-object). After a content change, `valid` is `false`. It comes through with the post rather than being declared in the output schema. - `platformResults` (object[], optional): Each destination’s publishing state, as `get_post_status` returns it. May be absent if reading it back failed. - `platformPostId` (string, optional): The id `unschedule_platform` takes. May be absent. - `platform` (string, required): The destination. - `state` (string, required; one of: `published`, `failed`, `stuck`, `scheduled`, `pending`, `unknown`): What this destination is doing. - `scheduledAt` (string | null, optional): This destination’s own time. May be absent. - `url` (string | null, required): Set once the destination is live. - `error` (object | null, required): On a failed destination, `code`, `message` and `retryable`. `null` otherwise. - `publishedUrls` (string[], optional): Live links. May be absent. - `captionResults` (object[], optional): What a caption write did to each destination it targeted. May be absent. - `platform` (string, required): The destination. - `captionUpdated` (boolean, required): The caption was written. - `firstCommentUpdated` (boolean, required): The first comment was written. - `characterCount` (integer, required): The caption’s length. - `characterLimit` (integer, required): This destination’s limit. `0` means no limit is on record. - `skippedReason` (string, optional): Present only when this destination kept its old caption, and says why. - `captionWarning` (string, optional): Some destinations kept their old caption because the new one was too long for them. Written for people. May be absent. - `platformsAdded` (string[], optional): Destinations this call added. May be absent. - `platformsRemoved` (string[], optional): Destinations this call removed. May be absent. - `platformSetWarning` (string, optional): An added destination took a caption that is over its limit. Shorten it before you schedule. May be absent. - `scheduleHeld` (boolean, optional): Present when the post was scheduled before the call. `false` means it is no longer scheduled after the edit, or that Native could not check. Then read `get_post_status`, and call `schedule_post` again if the post is not booked. - `scheduleWarning` (string, optional): Why the schedule did not hold. Written for people. May be absent. - `replaced` (object, optional): Present when the call overwrote content the post already had. This is the only copy Native keeps. - `text` (boolean, required): The caption was overwritten. - `firstComment` (boolean, required): The first comment was overwritten. - `scheduledAt` (boolean, required): The intended time was overwritten. - `previousText` (string, optional): The caption before the call. - `previousFirstComment` (string, optional): The first comment before the call. - `previousScheduledAt` (string, optional): The intended time before the call. The examples on this page use `callNative` from [End-to-end flow](https://docs.native.no/native-api/flow#connect-to-the-gateway). It returns the tool’s payload, or throws a `NativeError` that carries the error’s `code` and `retryable`. Change a caption: ```ts // callNative and NativeError are defined in /docs/native-api/flow. const updated = await callNative('update_post', { id: '5a2f9c71-3e8d-4b60-a7f4-1c9e2d8b6f05', text: 'Autumn hours start on Monday: 7 to 18 on weekdays, 8 to 15 on Saturdays.', }) for (const result of updated.captionResults ?? []) { if (!result.captionUpdated) { console.warn(result.platform + ' kept its old caption: ' + result.skippedReason) } } // The earlier approval no longer covers the post: show it to the reviewer again. const needsReview = updated.approval?.valid === false ``` Response (excerpt): ```json { "postId": "5a2f9c71-3e8d-4b60-a7f4-1c9e2d8b6f05", "contentDigest": "v3.3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d", "approval": { "decision": "APPROVED", "reviewerName": "Kari Nordmann", "reviewerEmail": "kari@example.com", "reason": null, "decidedAt": "2026-10-12T09:14:00.000Z", "approvedDigest": "v3.ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "valid": false }, "captionResults": [ { "platform": "INSTAGRAM", "captionUpdated": true, "firstCommentUpdated": false, "characterCount": 72, "characterLimit": 2200 } ], "replaced": { "text": true, "firstComment": false, "scheduledAt": false, "previousText": "Autumn hours start on Monday. We open at 7 on weekdays, so the first cinnamon buns are out before your train." }, "appOrigin": "https://app.native.no", "appUrl": "https://app.native.no/api/open-post?id=5a2f9c71-3e8d-4b60-a7f4-1c9e2d8b6f05" } ``` ### Errors | Code | `retryable` | What to do | | --- | --- | --- | | `post_already_published` | `false` | `publishedPlatforms` lists what is live. Take those destinations down with `unpublish_post` if the post has to change, or make a new draft. | | `caption_over_limit` | `false` | No destination accepted the text, so nothing was written. `characterCount` is the length you sent, `characterLimit` the limit to fit and `overLimitPlatforms` the destinations that refused it. Shorten the text, or write the long version to one destination with `platform`. | | `already_scheduled` | `false` | A publishing provider holds the post’s time, so `scheduledAt: null` cannot clear it. `lockedDestinations` lists each held destination as `platform` and `platformPostId`. Withdraw each one with `unschedule_platform`, or move the post with `schedule_post`. | | `platform_not_connected` | `false` | A destination in `addPlatforms` has no live connection. Read `unavailablePlatforms` and `connectedPlatforms`. | | `platform_not_supported` | `false` | Native does not publish to a destination in `addPlatforms`. Read `unsupportedPlatforms` and `supportedPlatforms`. | | `reddit_options_required` | `false` | `addPlatforms` includes `REDDIT`, but `reddit` was not sent. | | `media_required` | `false` | You added a story destination, and the post has no picture or video. Attach one first, or drop the story destination. | | `image_generation_in_progress` | `false` | A picture is still rendering, so the destinations cannot change yet: a destination added now would miss it. Poll `jobId` with `get_generation_job`, then try again. `jobId` can be missing. | | `bad_request` | `false` | Native refused the change, for example because it would remove every destination, or names a destination the post does not have. Fix what `invalidFields` and `fieldErrors` name. | | `upstream_409` | `false` | You tried to remove a destination that a publishing provider is holding. Withdraw it with `unschedule_platform` first. This refusal has no code of its own in the gateway today. | | `platform_scheduled` | `false` | New, in development: the same refusal as `upstream_409` above, under its own code. Withdraw the held destination with `unschedule_platform`, then remove it. Its `platformPostId` is in `get_post_status`. | Any tool can also return the common errors, such as `not_found`, `subscription_required` or `upstream_timeout`. [Errors](https://docs.native.no/native-api/errors) lists every code. ## Rewrite a caption MCP tool `generate_caption` (preview) Native rewrites a caption in the brand’s voice and replaces the current one. It keeps no copy of the caption it replaces, and this result has no `replaced`. If you may want the old caption back, read it with `view_post` first. Input: - `postId` (string, required): The post id. - `instructions` (string, optional): Up to 2,000 characters. Refines the caption the post holds now, so a caption that already meets them comes back nearly unchanged. With `fromBrief`, they steer the fresh text instead. - `platform` (string, optional): Rewrite one destination only, such as `LINKEDIN`, or `all`. That destination is refined from its own current copy, in that platform’s style, and no other caption changes. Without it, the post’s main caption is rewritten and copied to the destinations whose caption was never edited by hand. - `fromBrief` (boolean, optional): Write a fresh caption from the post’s original brief instead of refining the current one. Refused with `bad_request` on a post created without a brief. The result is everything `update_post` returns except `replaced`, plus `generatedCaption`. `captionResults` says exactly which destinations changed, so you can tell a hand-edited caption Native left alone from one it skipped. Output, in addition to update_post’s: - `generatedCaption` (string, optional): The caption Native wrote, as plain text. May be absent. ### Errors | Code | `retryable` | What to do | | --- | --- | --- | | `post_already_published` | `false` | `publishedPlatforms` lists what is live. Make a new draft instead. | | `usage_limit_exceeded` | `false` | The workspace’s AI allowance is used up. `resetDate` says when it refills. | | `ai_generation_failed` | `true` | Writing the caption failed. Try again. | | `bad_request` | `false` | For example `fromBrief: true` on a post created without a brief. Fix what `invalidFields` and `fieldErrors` name. | | `profile_not_ready` | `true` | New, in development: the profile `create_profile` started is not ready. Poll `get_profile` until `onboarding.status` is `ready`. | ## Redraw one picture MCP tool `regenerate_post_image` (preview) Redraws one picture of a post in its slot and leaves the other slides alone. The call returns at once with a `jobId`. The render runs in the background, and you follow it with `get_generation_job`. Do not call it again to find out how a render went: poll its job. While a render runs, a second call is refused with `image_generation_in_progress`. Once the render has ended, a second call starts a new one. Input: - `postId` (string, required): The post id. - `index` (integer, optional): The slide to redraw, from 0 to 99: its position in `imageUrls`. Send exactly one of `index` or `imageId`. `view_post` returns no image ids, so `index` is usually the one to use. - `imageId` (string, optional): The image’s own id, such as an `images[].imageId` from an earlier result of this tool. - `prompt` (string, required): What to change about this slide. 1 to 2,000 characters. Describe the subject, framing, mood and setting the change touches, and write names and facts exactly as they should appear, because the image model invents what the prompt leaves out. Leave out the brand’s styling and the caption. - `referenceImageUrls` (string[], optional): Up to 9 distinct URLs of pictures in the brand’s Native library, to steer the render. The current slide stays the base. Any other URL is refused. The schema allows 10, but Native refuses more than 9 distinct URLs with `bad_request`, because the slide being redrawn takes the tenth place. Output: - `postId` (string, required): The post id. - `images` (object[], required): The post’s slots as they are now. The new picture is not here yet. - `index` (integer, required): The slot’s position, from 0. - `url` (string, required): The picture in the slot now. - `filename` (string, required): The file’s name. - `filetype` (string, optional): Tells a picture from a video. May be absent. - `altText` (string | null, required): The alt text written for the picture, or `null`. - `imageId` (string, required): The image’s own id. - `variants` (object[], required): One per destination. - `platform` (string, required): The destination. - `platformPostId` (string, required): The destination’s own id. - `imageCount` (integer, required): How many pictures the destination carries. - `imageGeneration` (object, required): The render that just started. - `jobId` (string, required): Poll it with `get_generation_job`. - `status` (string, required; one of: `queued`): Always `queued`. ### Aspect ratio The new picture is drawn at the ratio of the first real destination holding that slot. That is 9:16 for `INSTAGRAM_STORIES`, `FACEBOOK_STORIES` and `TIKTOK`, and 4:5 for `INSTAGRAM`, `FACEBOOK`, `LINKEDIN` and `TWITTER`. 4:5 is also the default. On a stories-only post, the new picture is 9:16. ### Workspaces that render from templates > **In development.** On a workspace that renders posts from design templates, `regenerate_post_image` is refused with `template_rendering`. Use `generate_image` instead: it redraws the whole design, so the slide text stays exact. It replaces all of the post’s pictures, not one slide. A template render takes a few minutes. `generate_image` with `saveToLibrary` is refused there with the same code. These pages do not cover `generate_image` yet: `tools/list` has its schema. ### Errors | Code | `retryable` | What to do | | --- | --- | --- | | `post_already_published` | `false` | `publishedPlatforms` lists what is live. Make a new draft instead. | | `post_already_scheduled` | `false` | A publishing provider holds the post, so its media is frozen. `lockedDestinations` lists every held destination with its `platformPostId`. Call `unschedule_platform` once per entry, for all of them, then redraw and schedule again. Retrying before that fails the same way. | | `image_generation_in_progress` | `false` | A render is already running on this post. Poll its `jobId` with `get_generation_job` instead of calling again. `jobId` can be missing. | | `usage_limit_exceeded` | `false` | The workspace’s AI allowance is used up. `resetDate` says when it refills. | | `bad_request` | `false` | For example a reference URL from outside the brand’s Native library, or more than 9 distinct reference URLs. Fix what `invalidFields` and `fieldErrors` name. | | `profile_not_ready` | `true` | New, in development: the profile `create_profile` started is not ready. Poll `get_profile` until `onboarding.status` is `ready`. | | `template_rendering` | `false` | New, in development: the workspace renders posts from design templates. Use `generate_image` instead. | ## Follow a render MCP tool `get_generation_job` (preview) Reports how a picture render is going, and how it ended. By default the call waits up to about 24 seconds for the render to finish before it answers. Input: - `jobId` (string, required): From `imageGeneration.jobId`, or from an `image_generation_in_progress` error. - `wait` (boolean, optional; default: `true`): With `true`, the call holds for up to about 24 seconds until the render settles. With `false`, it returns the current state at once. Output: - `jobId` (string, required): The render. - `kind` (string, required; one of: `image`): Always `image`. - `operation` (string, required): What started the render, for example `POST_REGENERATE` for `regenerate_post_image`. Treat it as an open set. - `status` (string, required; one of: `queued`, `generating`, `finalizing`, `completed`, `failed`): Where the render is. `completed` and `failed` are final. - `postId` (string | null, required): The post the render is for. - `brandId` (string, required): The brand. - `companyId` (string, required): The client’s workspace. - `createdAt` (ISO 8601 string, required): When the render was asked for. - `startedAt` (ISO 8601 string | null, required): When it started. - `completedAt` (ISO 8601 string | null, required): When it ended. - `error` (object | null, required): Why the render failed. `null` unless `status` is `failed`. - `code` (string, required; one of: `content_rejected`, `provider_timeout`, `provider_unavailable`, `quota_exhausted`, `generation_failed`): Branch on this. See [render failures](https://docs.native.no/native-api/editing-posts#render-failures). - `message` (string, required): Written for people. It can change at any time. - `retryable` (boolean, required): Whether a new render could succeed without a change. - `outputs` (object[], required): One per requested slide, so you can see a render where one slide failed. - `index` (integer, required): The slide, from 0. - `status` (string, required; one of: `pending`, `completed`, `failed`): Where this slide is. - `imageUrl` (string | null, required): The new picture, once it lands. - `error` (object | null, required): Why this slide failed, in the same shape as `error`. - `imageUrls` (string[], required): The pictures this render produced. Empty until they land. - `libraryImageIds` (string[], optional): Library ids for the produced pictures, only for a render started with `saveToLibrary` on `generate_image`. Empty otherwise. May be absent. - `post` (object, optional): Present once the render has settled and the post could be read: the post with its new pictures. It carries `view_post`’s preview fields, `appOrigin` and `appUrl`, but no `contentDigest` or `approval`. - `authorName` (string, required): As in `view_post`. - `authorSubtitle` (string | null, required): As in `view_post`. - `authorAvatarUrl` (string | null, required): As in `view_post`. - `postText` (string, required): As in `view_post`. - `imageUrl` (string | null, required): As in `view_post`. - `imageUrls` (string[], required): As in `view_post`. - `destinations` (object[], required): As in `view_post`. See [the destination object](https://docs.native.no/native-api/generating-posts#destination-object). - `appOrigin` (string, required): The Native app’s address. - `appUrl` (string, required): The post in the Native app. Errors: `not_found` when no render has that id, and `forbidden` for a render in a workspace you cannot reach, plus the other common errors. ### Render failures A failed render has ended, so starting a new one is safe. Do it only when `retryable` is `true`, or after you have changed what the code points at. | Code | `retryable` | What it means | | --- | --- | --- | | `content_rejected` | `false` | The image provider refused the request, for example on content policy. Change the prompt before you try again. | | `provider_timeout` | `true` | The image provider took too long. Start a new render. | | `provider_unavailable` | `true` | The image provider was busy or down. Start a new render after a pause. | | `quota_exhausted` | `false` | The image provider refused for quota or billing reasons. A new render will not help. Contact Native if it persists. | | `generation_failed` | `true` | Any other failure. A new render may succeed. | ### Polling - Call with the default `wait: true`. Each call already waits about 24 seconds, so call again straight away while `status` is `queued`, `generating` or `finalizing`. - Or call with `wait: false` every 5 seconds. - Stop when `status` is `completed` or `failed`. - A template render takes a few minutes, so expect several calls. - Never call `regenerate_post_image` again to retry a render that is still running. Redraw a slide and wait for it: ```ts async function redrawSlide(postId: string, index: number, prompt: string) { let jobId: string try { const started = await callNative('regenerate_post_image', { postId, index, prompt }) jobId = started.imageGeneration.jobId } catch (err) { // A render is already running on this post: follow that one instead. if (!(err instanceof NativeError) || err.code !== 'image_generation_in_progress') throw err const runningJobId = (err.body as { jobId?: string }).jobId if (!runningJobId) throw err jobId = runningJobId } // Each call waits up to about 24 s, so 20 calls cover about 8 minutes. for (let attempt = 0; attempt < 20; attempt++) { const job = await callNative('get_generation_job', { jobId }) if (job.status === 'completed' || job.status === 'failed') return job } throw new Error('Render ' + jobId + ' is still running') } const job = await redrawSlide( '5a2f9c71-3e8d-4b60-a7f4-1c9e2d8b6f05', 0, 'The same counter and buns in early morning light, with the sign in the window reading "Open from 7"', ) if (job.status === 'failed') console.warn(job.error?.code, job.error?.retryable) // The post has a new picture and a new contentDigest. Read it with view_post before review. ``` Response: ```json { "jobId": "a41c7e2d-9b8f-4d36-8e15-0f2a6b9c3d70", "kind": "image", "operation": "POST_REGENERATE", "status": "completed", "postId": "5a2f9c71-3e8d-4b60-a7f4-1c9e2d8b6f05", "brandId": "8f0c2b7e-1d3a-4c5f-9e8b-7a6d5c4b3a21", "companyId": "c2a7e9d1-5b3f-4e8a-a1c6-9d0b2f4e7a58", "createdAt": "2026-10-12T09:20:00.000Z", "startedAt": "2026-10-12T09:20:02.000Z", "completedAt": "2026-10-12T09:20:41.000Z", "error": null, "outputs": [ { "index": 0, "status": "completed", "imageUrl": "https://storage.googleapis.com/example-bucket/posts/5a2f/slide-1-v2.png", "error": null } ], "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/5a2f/slide-1-v2.png"], "libraryImageIds": [], "post": { "authorName": "Solsiden Bakery", "authorSubtitle": "Draft", "authorAvatarUrl": "https://storage.googleapis.com/example-bucket/logos/solsiden.png", "postText": "Autumn hours start on Monday: 7 to 18 on weekdays, 8 to 15 on Saturdays.", "imageUrl": "https://storage.googleapis.com/example-bucket/posts/5a2f/slide-1-v2.png", "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/5a2f/slide-1-v2.png"], "destinations": [ { "platform": "INSTAGRAM", "text": "Autumn hours start on Monday: 7 to 18 on weekdays, 8 to 15 on Saturdays.", "firstComment": null, "title": null, "imageUrls": ["https://storage.googleapis.com/example-bucket/posts/5a2f/slide-1-v2.png"], "thumbnailUrl": null, "characterCount": 72, "characterLimit": 2200, "publishingOptions": null } ], "appOrigin": "https://app.native.no", "appUrl": "https://app.native.no/api/open-post?id=5a2f9c71-3e8d-4b60-a7f4-1c9e2d8b6f05" } } ``` ## After an edit 1. Read the post again with `view_post`. A render’s `post` has no `contentDigest`, so you need this read. 2. Show the reviewer every destination from that read. 3. Call `approve_post` with that read’s `contentDigest`, or `reject_post` with a reason. See [Review and approval](https://docs.native.no/native-api/approval). 4. Schedule or publish only after that. A stale approval is refused at scheduling with `approval_stale`. --- # Review and approval Source: https://docs.native.no/native-api/approval > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Record a client’s approval of the exact version they saw, or a rejection with a reason, and see what Native checks before a post goes out. > **Partner access is not live yet.** The tools on this page are in Native’s gateway today, for people signed in to Native. Partner access with client-credentials tokens is in development, so a partner backend cannot call them yet. Anything marked **in development** is not deployed yet and can still change. [Authentication](https://docs.native.no/authentication) explains how partner access will work. Your client reviews posts in your own product. Native records each decision and checks it again when the post is scheduled or published. An approval names one exact version of a post. Every post carries a `contentDigest`, a stamp of everything that would be published. `approve_post` records the digest the reviewer saw. When the post is scheduled or published, Native works out the digest again. If the content has changed since the approval, the approval no longer counts. ## How a review works 1. Read the post with `view_post`. Keep its `contentDigest`. 2. Show the reviewer every destination in `destinations[]`: the caption, first comment, title, images, video cover and publishing options. The preview card alone is not the whole post. 3. Call `approve_post` with `expectedDigest` set to the `contentDigest` from that same read, and name the reviewer. Or call `reject_post` with their reason. 4. Schedule or publish the post. Native checks the decision again at that moment. See [scheduling and publishing](https://docs.native.no/native-api/publishing). Approve only once every image has landed. A picture that is still rendering changes the digest when it arrives, and the approval is then stale. **New, in development:** created items in `get_post_batch_status`, the result of `create_draft`, and `get_post_status` will carry the same `contentDigest` and `approval` as `view_post`. You will then be able to show and approve a new post without reading it again. Until that ships, read the post with `view_post` first. ## What the digest covers `contentDigest` is an opaque string. Today it looks like `v3.` followed by a SHA-256 hash, but the format can change. Compare it only for equality. It covers which destinations the post has and, for each destination: - the caption - the first comment - the publishable title - the video cover (`thumbnailUrl`) - the publishing options, such as a Reddit subreddit or TikTok visibility - the image URLs, in order - the alt text of its pictures, when any picture on that destination has some (new, in development) - the post’s own title, on a LinkedIn or TikTok destination without a title of its own (new, in development) - the subtitle files sent with its videos It does not cover the scheduled time. Moving a post to another time keeps its approval. Any edit changes the digest, whichever way it is made: through this API, in the Native app, or by Native’s own image workers. Adding or removing a destination changes it too. A changed image always has a new URL, so it always gives a new digest. `view_post.destinations[]` shows everything the digest covers except the subtitle files, which this API does not return. On a video post, a subtitle change rolls the digest even though nothing you can show the reviewer has changed. If you review video posts, you cannot show their subtitles from this API yet. New, in development: the same goes for the post’s own title. A LinkedIn or TikTok destination without a title of its own publishes the post’s title instead, and a LinkedIn one falls back to the brand’s name when the post has no title. `destinations[]` shows each destination’s own title and options, not the post’s title, so a change to the post’s title in the Native app can make an approval stale with nothing new to show. ## Approve a post MCP tool `approve_post` (preview) Records approval of the post’s exact current content. Input: - `id` (string, required): The post id. - `expectedDigest` (string, required): The `contentDigest` from the read the reviewer actually saw. 1 to 200 characters. - `reviewerName` (string, optional): The person in your product who approved. 1 to 200 characters. - `reviewerEmail` (string, optional): That person’s email address. A valid address of up to 320 characters. If you send neither reviewer field, Native records the caller as the reviewer. It records the caller beside the reviewer either way. Show the post, then approve it: ```ts const viewed = await client.callTool( { name: "view_post", arguments: { id: postId } }, undefined, { timeout: 150_000 }, ); const post = (viewed.structuredContent as { data: any }).data; // Show post.destinations to the reviewer. When they approve: const result = await client.callTool( { name: "approve_post", arguments: { id: postId, expectedDigest: post.contentDigest, reviewerName: "Kari Nordmann", reviewerEmail: "kari@example.com", }, }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any }).data; if (result.isError) { if (data.error.code === "post_content_changed") { // The post changed after the reviewer saw it. // Read it again and show it to them again. } } else { console.log(data.approval.valid); // true } ``` Never resend with a fresher digest that the reviewer did not see. If the post changed, show it again and let them decide again. Output: - `id` (string, required): The post id. - `postId` (string, optional): The same value as `id`. New, in development: until the declared output schema ships, this field is absent. - `contentDigest` (string | null, required): The digest the decision was recorded against. - `approval` (object | null, required): The decision just recorded, as described in [the approval object](https://docs.native.no/native-api/approval#approval-object). After an approval, `valid` is `true`. - `appOrigin` (string, optional): New, in development: the origin of the Native app. Absent until then. - `appUrl` (string, optional): New, in development: a link to the post in the Native app. Absent until then. The rest of the post comes back too, without a declared schema: `title`, `status`, `scheduledAt`, `companyId`, `brandId`, `owner`, `origin`, `groupKey`, `platformVariants[]` with captions as plain text, `createdAt` and `updatedAt`. Ignore any field you do not use. | Code | Retryable | Meaning | | --- | --- | --- | | `post_content_changed` | `false` | The post changed after the read the reviewer saw. Nothing was recorded. Read the post again and have it reviewed again. | | `post_already_published`, `already_published` | `false` | A destination of the post is live, and a post with a live destination can no longer be reviewed. `publishedPlatforms` lists the destinations that are live. Which of the two codes you get depends on the post’s state, so handle both. | | `not_found` | `false` | The id is wrong, or the post is not yours. | | `bad_request` | `false` | The input is invalid. `invalidFields` and `fieldErrors` say what to fix. | Any tool can also return the common codes, such as `internal_error` or `upstream_timeout`. They are listed under [errors](https://docs.native.no/native-api/errors). ## Reject a post MCP tool `reject_post` (preview) Records a rejection with a reason. While the latest decision is a rejection, `schedule_post` and `publish_post_now` refuse the post with `approval_rejected`. Editing the post does not clear a rejection. Only a new `approve_post` does, so the post needs a fresh review after it is fixed. Input: - `id` (string, required): The post id. - `reason` (string, required): Why the reviewer turned the post down, in their words. 1 to 2,000 characters. It comes back as `approval.reason`. - `expectedDigest` (string, optional): The `contentDigest` the reviewer saw, so the rejection names that version. Optional, but recommended. 1 to 200 characters. - `reviewerName` (string, optional): The person in your product who rejected the post. 1 to 200 characters. - `reviewerEmail` (string, optional): That person’s email address. A valid address of up to 320 characters. Reviewer identity works as in `approve_post`. The output has the same shape as `approve_post`’s, with `approval.decision` set to `REJECTED`. | Code | Retryable | Meaning | | --- | --- | --- | | `post_content_changed` | `false` | Only when you sent `expectedDigest`: the post changed after the reviewer saw it. Nothing was recorded. Read it again before deciding. | | `already_scheduled` | `false` | New, in development: the post is booked to publish, and a rejection would not stop it. Nothing was recorded. Withdraw each booked destination with `unschedule_platform` first, then reject. This error carries no `lockedDestinations`, so read each destination’s `platformPostId` from `get_post_status`. | | `post_already_published`, `already_published` | `false` | A destination of the post is live, and a post with a live destination can no longer be reviewed. `publishedPlatforms` lists the destinations that are live. | | `not_found` | `false` | The id is wrong, or the post is not yours. | | `bad_request` | `false` | The input is invalid, for example a `reason` that is only spaces. `invalidFields` and `fieldErrors` say what to fix. | ## The approval object The latest decision on a post has the same shape everywhere it appears: in `view_post`, in the results of `approve_post` and `reject_post`, and (new, in development) in `get_post_status`, `create_draft` and created `get_post_batch_status` items. It is `null` until someone records a decision. Fields: - `decision` (string, required; one of: `APPROVED`, `REJECTED`): The latest decision on the post. - `reviewerName` (string | null, required): Who decided, as you named them, or the caller when you named nobody. - `reviewerEmail` (string | null, required): Their email address, when known. - `reason` (string | null, required): The reason given with a rejection. `null` on an approval. - `decidedAt` (ISO 8601 string, required): When the decision was recorded. - `approvedDigest` (string, required): The digest the decision was recorded against. A rejection carries it too, despite the name. - `valid` (boolean, required): `false` when the content has changed since the decision. approval: ```json { "decision": "APPROVED", "reviewerName": "Kari Nordmann", "reviewerEmail": "kari@example.com", "reason": null, "decidedAt": "2026-11-02T08:14:03.000Z", "approvedDigest": "v3.9f2c41d07a6b3e58c1d4f0a92b7e6c35d8a1f4e07b29c6d3a5e8f1b0c47d2a96", "valid": true } ``` ## What Native checks before a post goes out Each time a post is scheduled or published, Native works out its digest again and compares it with the latest decision. | Situation | Result | | --- | --- | | The latest decision is an approval and the digest matches. | Allowed. | | The content changed after the approval. | `approval_stale`. Read the post again, have it reviewed, and approve it again. | | The latest decision is a rejection. | `approval_rejected`, even after edits. | | No decision is on record, and the workspace requires approval. | New, in development: `approval_required`. Partner workspaces will require approval by default. Record a decision with `approve_post`. | | No decision is on record, and approval is not required. | Allowed. This is how every workspace behaves today. | | The workspace requires approval, and publishing would change the approved pictures, because the post’s design is stale or its media would need repair. | New, in development: `design_changed_after_approval`. Nothing is sent. Read the post again with `view_post`, have it reviewed, and approve it again. | All of these codes have `retryable: false`: something has to change before the same call can succeed. `schedule_post` and `publish_post_now` refuse the whole call. `reschedule_posts` checks each post on its own: a refusal shows up in that entry’s `moves[].error`, and the other entries still run. See [scheduling and publishing](https://docs.native.no/native-api/publishing). Today, a post with no decision on record can be scheduled and published on every workspace. If your product promises that nothing goes out without the client’s approval, enforce that on your side until `approval_required` ships. ## What may not change after approval > **In development.** This section describes behaviour that is still in development. Until it ships, Native can still re-render a stale design or repair a post’s media after approval, so the pictures that publish can differ from the ones the reviewer approved. On a workspace that requires approval, nothing Native does will change approved content: - No design re-render. A post whose design is stale is refused, not re-rendered. - No media repair, such as copying pictures from the main design onto a destination. - No re-crop. - No silent re-approval when someone presses Schedule in the Native app. The schedule fails with `approval_stale`, and the app asks for an explicit approval. - A LinkedIn document PDF is built from the approved images. - A branding change never re-renders a post that already exists, approved or not. The one change Native still makes is format conversion, for example to JPEG for Instagram. Where any other change would be needed, the publish is refused with `design_changed_after_approval` and nothing is sent. ### Recovering from a design change No tool renders a stale design on its own. An edit that re-renders the design, such as `generate_image`, produces new pictures. These pages do not cover `generate_image` yet: `tools/list` has its schema. Read the post again with `view_post`, have the new pictures reviewed, and approve the post again. ## Good practice - Keep the digest you showed next to the decision in your own records. It tells you later exactly which version the client approved. - Name the reviewer with `reviewerName` and `reviewerEmail`. Without them, the record names the caller, not the person who decided. - Read `approval.valid` before you schedule. `false` means the content changed after the decision. - After any edit, show the post again and ask for a new approval. `update_post`, `generate_caption`, `regenerate_post_image` and changes to the destinations all change the digest. - Pass a rejection’s `approval.reason` to whoever fixes the post. --- # Scheduling and publishing Source: https://docs.native.no/native-api/publishing > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Book a post for a time, move many at once, publish now, withdraw one destination, and follow each destination to a live link or a failure. > **Partner access is not live yet.** The tools on this page are in Native’s gateway today, for people signed in to Native. Partner access with client-credentials tokens is in development, so a partner backend cannot call them yet. Anything marked **in development**, such as `dryRun`, is not deployed yet and can still change. [Authentication](https://docs.native.no/authentication) explains how partner access will work. A post has one or more destinations, such as `INSTAGRAM` and `FACEBOOK`. Scheduling and publishing happen per destination: one can be live while another failed. So read the per-destination results, never one flag for the whole post. Native does not retry a failed publish on its own. A failed destination stays failed until you act. ## Before you schedule or publish - The brand needs a connected channel. `get_profile` reports it as `readiness.canPublish`. - The post has to pass the approval check. See [review and approval](https://docs.native.no/native-api/approval). - New, in development: you can rehearse the call first with [`dryRun: true`](https://docs.native.no/native-api/publishing#dry-run). Calls that contact platforms can take far longer than others. The gateway waits up to 120 seconds for Native before it answers `upstream_timeout`, so set your client’s timeout above that, for example 150 seconds. See [limits](https://docs.native.no/native-api/limits). ## The approval check `schedule_post` and `publish_post_now` check the post’s approval first. They refuse the whole call with `approval_rejected` or `approval_stale`. New, in development: also with `approval_required` or `design_changed_after_approval`. [Review and approval](https://docs.native.no/native-api/approval#approval-gate) explains each case. `reschedule_posts` checks each post on its own. A refusal shows up in that entry’s `moves[].error`, and the other entries still run. So a partly refused call is not untouched: do not resend the whole list. ## Schedule a post MCP tool `schedule_post` (preview) Books a post, or some of its destinations, for a time. It also moves a post that is already scheduled. That is one call, and the post stays booked throughout. Input: - `id` (string, required): The post id. - `scheduledAt` (ISO 8601 string, required): When to publish, with `Z` or a numeric offset, such as `2026-11-02T09:00:00+01:00`. Native stores it in UTC. A time in the past is refused on a post that is already scheduled. - `platforms` (string[], optional): Book only these destinations, as uppercase names such as `INSTAGRAM`. At least one when sent. Leave it out to book every destination. Destinations you do not name keep their own schedule. - `dryRun` (boolean, optional): New, in development: `true` runs every check and books nothing. See [rehearse with a dry run](https://docs.native.no/native-api/publishing#dry-run). Native stores the time before it hands the post to the platform. If no platform takes it, the post can carry a future date and still never publish. `scheduleConfirmed` is how you tell the two apart. Output: - `postId` (string, required): The post id. - `scheduleConfirmed` (boolean, required): Report the post as scheduled only when this is `true`. `false` means nothing confirms a platform holds the time, or a move did not fully land. - `scheduleWarning` (string, optional): Why the schedule could not be confirmed. Present when `scheduleConfirmed` is `false`. - `platformResults` (object[], optional): One entry per destination, as in [`get_post_status`](https://docs.native.no/native-api/publishing#platform-results). May be absent if reading the post back failed. - `publishedUrls` (string[], optional): Live links. Usually empty after scheduling. May be absent if reading the post back failed. No output schema is declared for the real call. The result also carries the post as it now reads, and the `view_post` preview fields (`authorName`, `authorSubtitle`, `authorAvatarUrl`, `postText`, `imageUrl`, `imageUrls`, `destinations`, `appOrigin`, `appUrl`), any of which may be absent. Ignore fields you do not use. Schedule a post: ```ts const result = await client.callTool( { name: "schedule_post", arguments: { id: postId, scheduledAt: "2026-11-02T09:00:00+01:00" }, }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any }).data; if (result.isError) { // data = { ok: false, error: { code, message, retryable, ... } } handleScheduleError(data.error); } else if (data.scheduleConfirmed) { // Booked. Poll get_post_status a few minutes after the time. } else { // Not confirmed. Show data.scheduleWarning, then check get_post_status. } ``` | Code | Retryable | Meaning | Extra fields | | --- | --- | --- | --- | | `schedule_preflight_failed` | `false` | Checked before any platform was contacted. Nothing was booked and the post is untouched. Fix every destination named, or leave them out with `platforms`, then call again. | `blockedDestinations[]` | | `schedule_failed` | Varies | At least one destination refused at the provider. Others may have booked. See [when a destination refuses](https://docs.native.no/native-api/publishing#schedule-failed). | `refusedDestinations[]`, `scheduledDestinations[]` | | `platform_not_connected` | `false` | The post has no destinations, and the brand has no connected channel. Send the client a link from `create_connect_link`. A destination whose own account is not connected shows up as `schedule_failed` with `channel_not_connected` instead. | `unavailablePlatforms`, `connectedPlatforms` | | `approval_rejected` | `false` | The latest decision is a rejection. | None | | `approval_stale` | `false` | The content changed after it was approved. | None | | `approval_required` | `false` | New, in development: the workspace requires approval and none is on record. | None | | `design_changed_after_approval` | `false` | New, in development: publishing would change the approved pictures. Nothing was sent. | None | | `post_already_published`, `already_published` | `false` | A destination of the post is live, so the post cannot be scheduled. Which of the two codes you get depends on the post’s state, so handle both. | `publishedPlatforms` | Common codes, such as `bad_request` and `upstream_timeout`, are listed under [errors](https://docs.native.no/native-api/errors). ### Checks before any platform is contacted Native checks what each destination will take before it contacts any platform. If any destination fails, the call fails with `schedule_preflight_failed` and lists every blocked destination in one answer. The same call, unchanged, fails the same way: it is a list of edits to make, not a thing to retry. structuredContent.data: ```json { "ok": false, "error": { "code": "schedule_preflight_failed", "message": "Gateway-written sentence for people", "retryable": false, "blockedDestinations": [ { "platform": "INSTAGRAM", "code": "caption_too_long", "characterCount": 2410, "characterLimit": 2200 } ] } } ``` blockedDestinations[] entry: - `platform` (string, required): The blocked destination, such as `INSTAGRAM`. - `code` (string, required; one of: `caption_too_long`, `media_required`, `image_aspect_invalid`, `too_many_images`, `video_required`, `video_not_supported`, `post_empty`, `reddit_subreddit_required`, `reddit_subreddit_not_found`, `reddit_flair_required`, `reddit_title_too_long`): The rule it breaks. Treat an unknown code as blocking. - `characterCount` (integer, optional): With `caption_too_long`: the caption’s length. - `characterLimit` (integer, optional): With `caption_too_long`: this destination’s limit. - `imageCount` (integer, optional): With `too_many_images`: how many images the post has. - `imageLimit` (integer, optional): With `too_many_images`: how many this destination takes. | Code | What it means | What to do | | --- | --- | --- | | `caption_too_long` | The caption is over this destination’s limit. | Shorten this destination’s own copy with `update_post` and `platform`, or rewrite it with `generate_caption`. | | `media_required` | The destination publishes nothing without a picture, as stories do. | Attach or generate an image. | | `image_aspect_invalid` | A picture is outside Instagram’s feed band of 0.75 to 1.91. | Replace it with a picture inside the band. | | `too_many_images` | The post has more images than this destination takes. | Remove images down to `imageLimit`. | | `video_required` | The destination takes exactly one video and no pictures, as YouTube does. | Use a post with one video, or leave the destination out. | | `video_not_supported` | The destination publishes no video. | Leave the destination out, or use pictures. | | `post_empty` | There is neither a caption nor media. | Add copy or a picture. | | `reddit_subreddit_required` | A Reddit destination needs a subreddit. | Set one with `update_post` and `reddit`. | | `reddit_subreddit_not_found` | The subreddit does not exist. | Name another subreddit. | | `reddit_flair_required` | The subreddit requires a flair, and the post has none. | The flair is chosen in the Native app. Send the client to the post’s `appUrl`. | | `reddit_title_too_long` | The Reddit title is over Reddit’s limit. | Shorten the title. | ### When a destination refuses `schedule_failed` means at least one destination refused at the provider. Others may have booked anyway. - `refusedDestinations[]` lists each refusal as `{ platform, code }`, with the codes under [failure codes](https://docs.native.no/native-api/publishing#failure-codes). - `scheduledDestinations[]` lists the destinations that booked. - The envelope’s `retryable` is `false` if any refusal is `media_invalid`, `channel_not_connected`, `channel_needs_reauth` or `content_rejected`, and `true` otherwise. When `retryable` is `true`, call `schedule_post` again unchanged. It books only the destinations still outstanding. When it is `false`, fix what the codes name first. If there is no `refusedDestinations`, read `get_post_status` for the reason before you change anything. ## Schedule several posts MCP tool `reschedule_posts` (preview) Schedules or moves up to 25 posts in one call. Despite the name, it books drafts for the first time too. Entries run in the order you send them, and one failure does not stop the rest. Each entry gets the same checks as `schedule_post`, including the pre-flight and the approval check. `reschedule_posts` has no `dryRun`. Input: - `moves` (object[], required): The posts to book or move. 1 to 25 entries. - `postId` (string, required): The post id. - `scheduledAt` (ISO 8601 string, required): When to publish this post, with `Z` or a numeric offset. - `platforms` (string[], optional): Book only these destinations of this post. At least one when sent. Leave it out to book them all. Output: - `moves` (object[], required): One entry per entry you sent, in the same order. - `postId` (string, required): The post id. - `scheduledAt` (string, required): The time as you sent it. - `status` (string, required; one of: `rescheduled`, `unconfirmed`, `failed`): `rescheduled`: a platform holds the time, for a first booking as much as a move. `unconfirmed`: Native accepted the time, but nothing confirms a platform holds it. `failed`: see `error`. - `platformResults` (object[], optional): This post’s destinations, as in [`get_post_status`](https://docs.native.no/native-api/publishing#platform-results). Also on a failed entry the provider refused. - `scheduleWarning` (string, optional): Why an `unconfirmed` entry is not confirmed. - `error` (object, optional): On a `failed` entry. The codes are the same as `schedule_post`’s. - `code` (string, required): Branch on this. - `message` (string, required): A sentence for people. It can change at any time. - `retryable` (boolean, required): Whether this entry, unchanged, could succeed. - `refusedDestinations` (object[], optional): With `schedule_failed`: each refusal as `{ platform, code }`. - `scheduledDestinations` (string[], optional): With `schedule_failed`: the destinations that booked anyway. - `blockedDestinations` (object[], optional): With `schedule_preflight_failed`: the [blocked destinations](https://docs.native.no/native-api/publishing#preflight). Nothing was booked for this post. - `rescheduled` (integer, required): How many entries have `status: "rescheduled"`. - `failed` (integer, required): How many entries have `status: "failed"`. An `unconfirmed` entry counts in neither total, so `rescheduled` plus `failed` can be less than the number of entries. structuredContent.data: ```json { "moves": [ { "postId": "3f6a2c1e-8b4d-4f7a-9e2b-5c0d1a7e4b93", "scheduledAt": "2026-11-02T09:00:00+01:00", "status": "rescheduled", "platformResults": [ { "platformPostId": "a1c9e7b2-4d6f-4a8e-b3c5-0f2d9e1a7b64", "platform": "FACEBOOK", "state": "scheduled", "scheduledAt": "2026-11-02T08:00:00.000Z", "url": null, "error": null } ] }, { "postId": "7b0e5d9a-2c4f-4e1b-8a6d-3f9c0b2e5a71", "scheduledAt": "2026-11-03T09:00:00+01:00", "status": "failed", "error": { "code": "approval_stale", "message": "Gateway-written sentence for people", "retryable": false } } ], "rescheduled": 1, "failed": 1 } ``` After a partly failed call, fix the failed entries and send only those. Check `unconfirmed` entries with `get_post_status` before you send them again. ## Publish now MCP tool `publish_post_now` (preview) Publishes now. Calling it again retries only the destinations that failed. A destination that already published is never dispatched again. The post goes live on the client’s real accounts. `unpublish_post` can take some destinations down again, but Native cannot remove a post from Instagram, TikTok or a story yet. Treat publishing as final. Input: - `id` (string, required): The post id. - `platforms` (string[], optional): Publish or retry only these destinations. At least one when sent. Naming a destination the post does not have is a `bad_request`. - `dryRun` (boolean, optional): New, in development: `true` runs every check and publishes nothing. See [rehearse with a dry run](https://docs.native.no/native-api/publishing#dry-run). Output: - `postId` (string, required): The post id. - `publishResults` (object[], required): One entry per destination. - `platform` (string, required): The destination, such as `INSTAGRAM`. - `status` (string, required; one of: `published`, `pending`, `failed`, `skipped`): Only `published` means the destination is live. - `url` (string | null, required): The live link, once published. - `reason` (object | null, required): Why the destination is not published. `null` when it is. - `code` (string, required): Branch on this. - `message` (string, required): A sentence for people. It can change at any time. - `retryable` (boolean, required): Whether the same call, unchanged, could succeed. - `published` (integer, required): How many destinations are live. - `failed` (integer, required): How many destinations failed. - `skipped` (integer, required): How many destinations were skipped. - `pending` (integer, optional): How many destinations are still finishing. May be absent. - `platformResults` (object[], optional): As in [`get_post_status`](https://docs.native.no/native-api/publishing#platform-results). May be absent. - `publishedUrls` (string[], optional): Live links. May be absent. The result can also carry the `view_post` preview fields. Any of them may be absent. structuredContent.data: ```json { "postId": "3f6a2c1e-8b4d-4f7a-9e2b-5c0d1a7e4b93", "publishResults": [ { "platform": "FACEBOOK", "status": "published", "url": "https://www.facebook.com/123456789012345/posts/987654321098765", "reason": null }, { "platform": "INSTAGRAM", "status": "pending", "url": null, "reason": { "code": "publishing_in_progress", "message": "Sentence for people", "retryable": false } } ], "published": 1, "failed": 0, "skipped": 0, "pending": 1 } ``` | Code | Status | What it means | What to do | | --- | --- | --- | --- | | `publishing_in_progress` | `pending` | The platform accepted the post and is finishing it on its own. Instagram does this routinely. | Do not retry. Poll `get_post_status`, where the destination reads `scheduled` until it lands. | | `already_published` | `skipped` | The destination is already live. | Nothing. Publishing again would duplicate it. | | `held_by_provider` | `skipped` | The destination is booked for a scheduled time. | If it should go now, call `unschedule_platform` on it first. | | `stuck_at_provider` | `skipped` | The provider took the destination and never reported back. | Do not retry: it could duplicate the post. Contact Native support. | | `story_partially_published` | `skipped` | Some frames of the story are live. | Do not retry: it would send every frame again. Make a new post from the frames that did not publish. | | `not_requested` | `skipped` | The destination was not named in `platforms`. | Nothing. | | A [failure code](https://docs.native.no/native-api/publishing#failure-codes) | `failed` | The destination did not publish. | Follow `retryable`. | | Code | Retryable | Meaning | | --- | --- | --- | | `approval_rejected` | `false` | The latest decision is a rejection. Nothing was published. | | `approval_stale` | `false` | The content changed after it was approved. Nothing was published. | | `approval_required` | `false` | New, in development: the workspace requires approval and none is on record. | | `design_changed_after_approval` | `false` | New, in development: publishing would change the approved pictures. Nothing was sent. | | `bad_request` | `false` | The input is invalid, for example a destination the post does not have, or a post with no destinations. | An account that is not connected does not fail the whole call. That destination comes back `failed` with `channel_not_connected` or `channel_needs_reauth` in `publishResults`, while the others go out. ## Rehearse with a dry run > **In development.** `dryRun` is in development and not deployed. Today the input schema does not allow it, so the MCP SDK rejects a call that sends it before the tool runs. `schedule_post` and `publish_post_now` will take `dryRun: true`. Native runs every check the real call runs and stops before its first write. Nothing is booked, published, written or sent to any platform. A dry run needs the same `mcp:write` scope as the real call. schedule_post output with dryRun: - `dryRun` (boolean, required): Always `true`. - `postId` (string, required): The post id. - `scheduledAt` (ISO 8601 string, required): The time you sent, in UTC. - `wouldSchedule` (string[], required): The destinations the real call would book. - `blocked` (object[], required): One entry per destination the real call would not book. - `platform` (string, required): The destination that would not go out, such as `INSTAGRAM`. - `code` (string, required): Why. Branch on this. - `message` (string, required): A sentence for people, written by Native. It can change at any time. - `characterCount` (integer, optional): With `caption_too_long`: the caption’s length. - `characterLimit` (integer, optional): With `caption_too_long`: this destination’s limit. - `imageCount` (integer, optional): With `too_many_images`: how many images the post has. - `imageLimit` (integer, optional): With `too_many_images`: how many this destination takes. publish_post_now output with dryRun: - `dryRun` (boolean, required): Always `true`. - `postId` (string, required): The post id. - `wouldPublish` (string[], required): The destinations the real call would dispatch. - `blocked` (object[], required): One entry per destination the real call would not publish, in the same shape as for `schedule_post`. - `platform` (string, required): The destination that would not go out, such as `INSTAGRAM`. - `code` (string, required): Why. Branch on this. - `message` (string, required): A sentence for people, written by Native. It can change at any time. - `characterCount` (integer, optional): With `caption_too_long`: the caption’s length. - `characterLimit` (integer, optional): With `caption_too_long`: this destination’s limit. - `imageCount` (integer, optional): With `too_many_images`: how many images the post has. - `imageLimit` (integer, optional): With `too_many_images`: how many this destination takes. schedule_post with dryRun: structuredContent.data: ```json { "dryRun": true, "postId": "3f6a2c1e-8b4d-4f7a-9e2b-5c0d1a7e4b93", "scheduledAt": "2026-11-02T08:00:00.000Z", "wouldSchedule": ["FACEBOOK"], "blocked": [ { "platform": "INSTAGRAM", "code": "caption_too_long", "message": "Gateway-written sentence for people", "characterCount": 2410, "characterLimit": 2200 } ] } ``` | Code | Call | What it means | | --- | --- | --- | | The [pre-flight codes](https://docs.native.no/native-api/publishing#preflight) | Both | What the destination will not take. The real `publish_post_now` does not run these checks, so it would only learn them as a failed destination while the rest went live. | | `channel_not_connected` | Both | No account is connected for this platform. | | `channel_needs_reauth` | Both | The account was disconnected and the client has to reconnect it. | | `brand_inactive` | Both | The brand is not active, so nothing publishes for it. | | `platform_not_supported` | Both | This destination cannot publish through the brand’s current provider. | | `tiktok_video_not_public` | Both | This TikTok account can only post videos as public. | | `tiktok_photo_ai_label` | Both | This TikTok account cannot post photos marked as AI-generated. | | `publishing_in_progress` | `schedule_post` | A booked destination being moved is publishing right now, so it cannot be moved. | | `already_published`, `held_by_provider`, `stuck_at_provider`, `story_partially_published` | `publish_post_now` | The skip reasons of the real call, described under [publish now](https://docs.native.no/native-api/publishing#publish-now). | Treat the codes as an open set. A code you do not know still means the destination would not go out. Refusals of the whole post stay errors, and a dry run fails with them exactly as the real call would. For both tools: the approval check, a post with no destinations, and an unknown platform in `platforms`. For `schedule_post` also: a post with a live destination, and moving a booked post into the past. In a publish dry run, destinations you did not name in `platforms` are left out. An empty `blocked` list is not a guarantee. A few checks need the platform itself, so they run only in the real call, and a platform can still refuse at dispatch. A dry run reads the post as it stands at that moment, so run it close to the real call. Rehearse, then schedule: ```ts async function call(name: string, args: Record) { const result = await client.callTool( { name, arguments: args }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any } | undefined)?.data; return { isError: result.isError === true, data }; } const when = "2026-11-02T09:00:00+01:00"; const rehearsal = await call("schedule_post", { id: postId, scheduledAt: when, dryRun: true, }); if (rehearsal.isError) { handleScheduleError(rehearsal.data.error); // for example approval_stale } else if (rehearsal.data.blocked.length > 0) { // Show rehearsal.data.blocked to the client and fix those destinations. } else { const booked = await call("schedule_post", { id: postId, scheduledAt: when }); // Check booked.isError and booked.data.scheduleConfirmed as usual. } ``` ## Withdraw a destination MCP tool `unschedule_platform` (preview) Withdraws one destination from the provider. A post with three destinations takes three calls. The destination keeps its date, as a draft. To clear the date as well, call `move_platform_to_drafts` with the same `platformPostId`. Input: - `platformPostId` (string, required): The destination’s own id. Get it from `platformResults[].platformPostId` in `get_post_status`, or from a `lockedDestinations` entry in an error. Output: - `unscheduleConfirmed` (boolean, required): Treat the destination as withdrawn only when this is `true`. - `unscheduleWarning` (string, optional): Why the withdrawal could not be confirmed. Present when `unscheduleConfirmed` is `false`. - `stillScheduled` (string[], optional): The post’s other destinations that a provider still holds. - `keptScheduledAt` (string | null, optional): The date the withdrawn destination kept. - `postId` (string, optional): The post id. - `platformResults` (object[], optional): As in [`get_post_status`](https://docs.native.no/native-api/publishing#platform-results). - `publishedUrls` (string[], optional): Live links. No output schema is declared. The result also carries the post as it now reads. | Code | Retryable | Meaning | | --- | --- | --- | | `already_published` | `false` | A destination of the post is live, even one you did not name. Take it down with `unpublish_post` instead. | | `not_found` | `false` | The `platformPostId` is wrong, or not yours. | ## Follow each destination MCP tool `get_post_status` (preview) A status snapshot of a post, per destination. Use it to poll after scheduling or publishing. Input: - `id` (string, required): The post id. Output: - `id` (string, required): The post id. - `postId` (string, required): The same value as `id`. - `status` (string, required; one of: `Draft`, `Scheduled`, `Published`, `partially_published`, `error`): A summary for the whole post. Branch on `platformResults` instead. - `scheduledAt` (ISO 8601 string | null, required): The earliest time across the destinations. - `platformResults` (object[], required): One entry per destination. See [results per destination](https://docs.native.no/native-api/publishing#platform-results). - `publishedUrls` (string[], required): Live links. - `contentDigest` (string | null, optional): New, in development: the version an approval locks, the same value `view_post` returns. Until then, read it from `view_post`. - `approval` (object | null, optional): New, in development: the latest review decision, as in [the approval object](https://docs.native.no/native-api/approval#approval-object). Until then, read it from `view_post`. - `platformVariants` (object[], required): The raw form of each destination. Prefer `platformResults`. - `platform` (string, required): The destination. - `publishingStatus` (string | null, required): The raw publishing status. - `socialPostUrl` (string | null, required): The live link, when there is one. - `publishErrorMessage` (string | null, required): A sentence about a failure. Do not branch on it. ### Results per destination platformResults[] entry: - `platformPostId` (string, optional): The destination’s own id, which `unschedule_platform` and `move_platform_to_drafts` take. May be absent. - `platform` (string, required): The destination, as an uppercase name such as `INSTAGRAM`. - `state` (string, required; one of: `published`, `failed`, `stuck`, `scheduled`, `pending`, `unknown`): What this destination is doing. See the table below. - `scheduledAt` (string | null, optional): This destination’s own time. The post’s `scheduledAt` is the earliest across destinations, so the two can differ. May be absent. - `url` (string | null, required): The live link, once published. - `error` (object | null, required): Present on a failure. The codes are under [failure codes](https://docs.native.no/native-api/publishing#failure-codes). - `code` (string, required): Branch on this. - `message` (string, required): A sentence for people. It can change at any time. - `retryable` (boolean, required): Whether the same call, unchanged, could succeed. | State | What it means | What to do | | --- | --- | --- | | `published` | The destination is live. `url` is the link. | Nothing. | | `scheduled` | It is booked for its time. A destination the platform accepted from `publish_post_now` and is still finishing also reads `scheduled` until it lands. | Wait, and poll after its time. | | `pending` | No provider holds it yet: it is not booked, published or failed. A draft destination reads `pending`, and so does one you withdrew with `unschedule_platform`. | Schedule or publish it if it should go out. It will not go out on its own. | | `failed` | It did not publish. `error` says why. | Follow `error.retryable`. Native does not retry on its own. | | `stuck` | The provider accepted it, and its time passed with no outcome. | Stop polling and contact Native support. Do not publish again: it may have gone out. | | `unknown` | Native could not work out the state. The destination may be live. | Read the status again later. Do not publish again on this alone. | structuredContent.data: ```json { "id": "3f6a2c1e-8b4d-4f7a-9e2b-5c0d1a7e4b93", "postId": "3f6a2c1e-8b4d-4f7a-9e2b-5c0d1a7e4b93", "status": "partially_published", "scheduledAt": "2026-11-02T08:00:00.000Z", "platformResults": [ { "platformPostId": "a1c9e7b2-4d6f-4a8e-b3c5-0f2d9e1a7b64", "platform": "FACEBOOK", "state": "published", "scheduledAt": "2026-11-02T08:00:00.000Z", "url": "https://www.facebook.com/123456789012345/posts/987654321098765", "error": null }, { "platformPostId": "d4e8b1f6-9a2c-4c7e-8f0b-6a3d5e9c1b27", "platform": "INSTAGRAM", "state": "failed", "scheduledAt": "2026-11-02T08:00:00.000Z", "url": null, "error": { "code": "media_invalid", "message": "Sentence for people", "retryable": false } } ], "publishedUrls": ["https://www.facebook.com/123456789012345/posts/987654321098765"], "platformVariants": [ { "platform": "FACEBOOK", "publishingStatus": "SUCCESS", "socialPostUrl": "https://www.facebook.com/123456789012345/posts/987654321098765", "publishErrorMessage": null }, { "platform": "INSTAGRAM", "publishingStatus": "FAILED", "socialPostUrl": null, "publishErrorMessage": "Sentence for people" } ] } ``` ### Failure codes per destination These codes appear in `platformResults[].error.code`, `publishResults[].reason.code` and `refusedDestinations[].code`. Treat a code you do not know as its `retryable` flag says. | Code | Retryable | What to do | | --- | --- | --- | | `channel_not_connected` | `false` | Send the client a link from `create_connect_link`, then retry. | | `channel_needs_reauth` | `false` | The same: the client has to reconnect the account. | | `permission_denied` | `false` | The account lacks a permission, such as a page role. Reconnect with the right account. | | `rate_limited` | `true` | Retry later, with backoff. | | `provider_timeout` | `true` | Retry, but read `get_post_status` first, because it may have gone through. | | `provider_unavailable` | `true` | Retry later. | | `media_required` | `false` | Attach or generate media. | | `media_invalid` | `false` | Replace the image or video. The aspect ratio, size, format or duration is wrong for the platform. | | `content_rejected` | `false` | The platform refused the content, for example for policy or length. Edit it, have it approved again, and retry. | | `duplicate_content` | `false` | The platform sees it as a duplicate. Change the content or drop the destination. | | `validation_failed` | `false` | Fix what the message names. Do not retry unchanged. | | `unknown_error` | `false` | Do not retry blindly: it may have published. Read `get_post_status`, and contact support if it is unclear. | ## How often to poll | What | Call | How often | Done when | | --- | --- | --- | --- | | A scheduled publish | `get_post_status` | Once a few minutes after `scheduledAt`, then every 5 minutes for 30 minutes. | Every destination is `published` or `failed`. While one still reads `scheduled`, keep polling. On `stuck`, stop and contact support. | | After `publish_post_now` | `get_post_status` | Every 10 seconds for 2 minutes, then every 60 seconds for 10 minutes. | Every destination is `published` or `failed`. Only the destinations that came back `pending` in `publishResults` need polling. They read `scheduled` here until they land. | | Channel health | `get_profile` | Daily, and before you schedule. | An ongoing check. Act on `needs_reauth`, `disconnected` and `expiring_soon` before publishing breaks. | Webhooks are not available, so polling is the only way to learn the outcome. Back off with jitter on errors that are `retryable`, and stop on ones that are not. ## Retry safely - Respect `retryable`. Never repeat a call whose `retryable` is `false` without changing something first. - `publish_post_now` is safe to call again. It retries only failed destinations and never dispatches a published one again. `stuck_at_provider` and `story_partially_published` need support or a new post, not a retry. - After a timeout or a dropped connection on a write, read `get_post_status` before you retry. A lost response does not prove that nothing changed. - Sending the same tool call again is a new attempt, and Native does not deduplicate it. Keys you supply yourself are planned, not available. Until then, check the state before you retry `schedule_post` or `publish_post_now`. - `upstream_timeout` means Native did not answer within 120 seconds. The work may still be running, so read the state first. --- # Errors Source: https://docs.native.no/native-api/errors > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. How a Native API tool call fails: the error envelope, when to retry, and every error code with the fields that come with it. > **Not live for partners yet.** Partner access to the Native API is not live yet. Partner sign-in with client credentials is in development, so a partner backend cannot call these tools today. Codes without a marker are in the gateway that serves signed-in Native users today. Codes marked **New, in development** arrive with the tools and fields that raise them. Every way a Native tool can fail comes with a code. This page lists the codes a partner backend can meet, and what to do about each. Branch on the code. Never parse the message. **Native does not retry a failed publish on its own.** A destination that failed stays failed until you act. ## Where a failure shows up - **The tool refused the call.** The result has `isError: true` and carries the [error envelope](https://docs.native.no/native-api/errors#error-envelope). - **Part of the call failed.** Some tools succeed as a whole and report failures per destination or per post. See [failures inside a result](https://docs.native.no/native-api/errors#failures-inside-a-result). - **The request never reached a tool.** A token problem or the edge rate limit answers with an HTTP status and no envelope. See [errors outside the envelope](https://docs.native.no/native-api/errors#outside-the-envelope). - **An argument broke the input schema.** The MCP SDK rejects it before the tool runs, with its own validation error instead of the envelope. Treat that as a bug in your call. ## The error envelope When a tool refuses a call, the result has `isError: true`, and `structuredContent` is `{ "data": }`. `content[0].text` holds the same envelope as a JSON string. structuredContent.data: ```json { "ok": false, "error": { "code": "schedule_preflight_failed", "message": "A sentence Native wrote for people", "retryable": false, "blockedDestinations": [ { "platform": "INSTAGRAM", "code": "caption_too_long", "characterCount": 2410, "characterLimit": 2200 } ] } } ``` Envelope: - `ok` (boolean, required): Always `false` on a failure. - `error` (object, required): What went wrong. - `code` (string, required): The failure, as a stable code. Branch on this. - `message` (string, required): A sentence Native wrote for people. It can change at any time, so show it or log it, but never parse it. - `retryable` (boolean, required): Whether the same call, unchanged, could succeed. `false` means something has to change first. `retryable` answers one question: could the same call, sent again unchanged, succeed? It does not say the call did nothing. After a timeout, read the current state before you retry. See [retrying](https://docs.native.no/native-api/errors#retrying). ## Structured fields Some codes carry extra fields beside `code`, `message` and `retryable`. They sit on the same `error` object. Each one is named with the codes that use it. Destination names in these fields are uppercase `Platform` enum names, such as `INSTAGRAM`. Fields on error: - `invalidFields` (string[], optional): The input fields Native refused, as paths such as `reddit.subreddit` or `platforms.0`. On `bad_request`. New, in development: also on `font_not_found` and `logo_unreadable`, which each refuse one argument. - `fieldErrors` (object[], optional): On `bad_request`: a sentence for each refused field, where Native wrote one. `invalidFields` still names every refused field, including any with no sentence here. - `field` (string, required): The field path. - `message` (string, required): Why it was refused. - `blockedDestinations` (object[], optional): On `schedule_preflight_failed`, and in a `reschedule_posts` entry: every destination that will not publish the post as it stands. - `platform` (string, required): The destination. - `code` (string, required; one of: `caption_too_long`, `media_required`, `image_aspect_invalid`, `too_many_images`, `video_required`, `video_not_supported`, `post_empty`, `reddit_subreddit_required`, `reddit_subreddit_not_found`, `reddit_flair_required`, `reddit_title_too_long`): The rule it breaks. See [pre-flight codes](https://docs.native.no/native-api/errors#preflight-codes). - `characterCount` (integer, optional): With `caption_too_long`: the caption’s length. With `reddit_title_too_long`: the title’s length. - `characterLimit` (integer, optional): With `caption_too_long` or `reddit_title_too_long`: the destination’s limit. - `imageCount` (integer, optional): With `too_many_images`: how many pictures the post has. - `imageLimit` (integer, optional): With `too_many_images`: how many the destination takes. - `refusedDestinations` (object[], optional): On `schedule_failed`: every destination the provider refused. - `platform` (string, required): The destination. - `code` (string, required): Why it was refused. See [codes per destination](https://docs.native.no/native-api/errors#destination-codes). - `scheduledDestinations` (string[], optional): On `schedule_failed`: the destinations that hold a booking even though another one refused. - `lockedDestinations` (object[], optional): On `already_scheduled` and `post_already_scheduled`: every destination a publishing provider is holding. The `already_scheduled` that `reject_post` returns carries none, so read each `platformPostId` from `get_post_status`. - `platform` (string, required): The destination. - `platformPostId` (string, required): The id `unschedule_platform` takes to withdraw it. One call per entry. - `publishedPlatforms` (string[], optional): On `post_already_published` and `already_published`: the destinations that are live. - `unavailablePlatforms` (string[], optional): On `platform_not_connected`: the destinations the brand has no live connection to. - `connectedPlatforms` (string[], optional): On `platform_not_connected`: everything the brand can publish to now. It may be empty. - `unsupportedPlatforms` (string[], optional): On `platform_not_supported`: the destinations Native does not publish to at all. - `supportedPlatforms` (string[], optional): On `platform_not_supported`: the destinations this brand could publish to. - `supportedChannels` (string[], optional): New, in development: on `platform_not_supported` from `create_connect_link`, the channels Native can open a headless link for, in lowercase. - `overLimitPlatforms` (string[], optional): On `caption_over_limit`: the destinations whose limit the caption exceeds. - `characterCount` (integer, optional): On `caption_over_limit`: the length of the caption you sent. - `characterLimit` (integer, optional): On `caption_over_limit`: the limit the caption has to fit, the shortest among the destinations that refused it. - `resetDate` (ISO 8601 string, optional): On `usage_limit_exceeded`: when the workspace’s AI budget refills. - `jobId` (string, optional): On `image_generation_in_progress`: the render already running, for `get_generation_job`. Present when Native can say which render it is. - `websiteRefusal` (string, optional; one of: `BLOCKED_DOMAIN`, `URL_NOT_REACHABLE`, `URL_UNSAFE`, `URL_NOT_ALLOWED`): New, in development: on `invalid_website_url`, why the website was refused. See [website refusals](https://docs.native.no/native-api/errors#website-refusals). ## Error codes These are the codes a whole call can fail with, grouped by what they are about. The `retryable` column is the value the envelope carries. ### Codes any tool can return | Code | `retryable` | What it means | | --- | --- | --- | | `bad_request` | `false` | Invalid input. `invalidFields` names the refused fields, and `fieldErrors` says what is wrong with each where Native wrote a sentence. Fix those and call again. | | `not_found` | `false` | The id is wrong, or it is not yours. | | `forbidden` | `false` | This principal is not allowed to do this. | | `subscription_required` | `false` | The workspace has no active plan. | | `writes_disabled` | `false` | Writes are switched off on this deployment. Only tools that change data return it. | | `internal_error` | `true` | Native failed on its side. Retry shortly. | | `upstream_timeout` | `true` | Native did not answer within 120 seconds. The work may still be running, so read the current state before you retry. | | `upstream_unavailable` | `true` | Native could not be reached. Retry with backoff. | | `upstream_identity_failed` | `true` | The gateway could not authenticate itself to Native. Retry with backoff. | | `identity_provider_unavailable` | `true` | The gateway could not reach the identity provider to check your token, so the call was never authorised. You do not need a new token. Retry with backoff. | | `upstream_429` | `true` | Native is rate limiting. Back off, then retry. | | `upstream_` for another 4xx | `false` | Native refused the call with a code the gateway does not recognise, for example `upstream_409`. Change the call before you send it again. | | `upstream_` for a 5xx | `true` | Native failed with a code the gateway does not recognise. Retry with backoff. | ### Partner access | Code | `retryable` | What it means | | --- | --- | --- | | `partner_only` | `false` | New, in development: a partner-only tool was called without a partner principal. Nothing changed. | | `partner_not_found` | `false` | New, in development: the token is valid, but Native has no partner configured for its client id. Contact Native. Retrying will not help. | | `partner_downstream_auth_failed` | `true` | New, in development: the gateway could not get access to Native for your partner client. Your token is not the problem, so keep it. Retry with backoff. | ### Profiles | Code | `retryable` | What it means | | --- | --- | --- | | `profile_not_ready` | `true` | New, in development: a generating tool was called on a profile whose `create_profile` build is `building` or `failed`. That covers `create_posts`, `generate_caption`, `generate_image`, `regenerate_post_image`, ad generation, and `create_draft` when it is sent a `prompt` or `includeImage: true`. Poll `get_profile` until `onboarding.status` is `ready`. If it is `failed`, retry the build first: call `create_profile` again with the same `externalId`. | | `invalid_website_url` | `false` | New, in development: `create_profile` refused the website before it created anything. `websiteRefusal` says why. | | `stripe_billed_workspace` | `false` | New, in development: `deactivate_profile` or `delete_profile` was called on a workspace that pays for its own Native subscription. That subscription has to be cancelled in Native. | ### Branding | Code | `retryable` | What it means | | --- | --- | --- | | `font_not_found` | `false` | New, in development: `update_brand_style` named a font that is neither in the brand’s library nor a Google Fonts family. No role changed. `invalidFields` names `fonts.heading` or `fonts.body`. | | `logo_unreadable` | `false` | New, in development: the file given to `upload_brand_logo` is not a usable logo. It did not read as an image at least 16 px on each side, could not be fetched, or failed the upload scan. Nothing was saved. `invalidFields` names the argument. | | `source_not_https` | `false` | New, in development: `upload_brand_logo` was given a `sourceUrl` that is not https. Nothing was fetched. Send the same address as https. | | `source_not_public` | `false` | New, in development: `upload_brand_logo` was given a `sourceUrl` that points at a private or internal address. Nothing was fetched. Use an address anyone on the internet can reach. | | `design_style_unavailable` | `false` | New, in development: the brand is not on Design style, or its Design style cannot be read, so colour roles and fonts were not set. Palette colours can still be added on their own. | | `design_style_busy` | `true` | New, in development: another Design style save landed at the same moment, or Native was still setting up. Palette colours and library fonts the call added stay. Call again with the same arguments in a few seconds. | ### Connecting accounts | Code | `retryable` | What it means | | --- | --- | --- | | `platform_not_connected` | `false` | The brand has no live connection to a destination the call named or, for scheduling and publishing, no connected channel at all. `unavailablePlatforms` and `connectedPlatforms` say which. Drop those destinations, or have the client connect the account. | | `platform_not_supported` | `false` | Native does not publish to a destination the call named, however the brand is connected. `unsupportedPlatforms` and `supportedPlatforms` list both sides. New, in development: from `create_connect_link`, `supportedChannels` lists the channels it can open instead. | | `connect_session_expired` | `false` | New, in development: the `connectToken` expired or its link died. Mint a fresh link with `create_connect_link`. | | `headless_connect_unavailable` | `false` | New, in development: Native could not mint a headless link for this brand. Call `create_connect_link` without `platform` and `returnUrl` for a link to Native’s connect page. | ### Posts and generation | Code | `retryable` | What it means | | --- | --- | --- | | `usage_limit_exceeded` | `false` | The workspace’s AI budget is used up. Nothing AI-backed succeeds until `resetDate`. | | `ai_generation_failed` | `true` | AI generation failed. Retry. | | `media_required` | `false` | Every destination on the post is a story, and the post has no media. Attach a picture or video, or add a feed destination. | | `caption_over_limit` | `false` | The caption is longer than every destination on the post allows, so nothing was written. `characterCount`, `characterLimit` and `overLimitPlatforms` say by how much and where. | | `reddit_options_required` | `false` | A `REDDIT` destination needs `reddit: { subreddit }`, and none was sent. Send it, or drop `REDDIT`. | | `image_generation_in_progress` | `false` | A render is already running for this post. Poll it with `get_generation_job`, using `jobId` when the error carries one. Do not start another. | | `template_rendering` | `false` | New, in development: the workspace renders posts from design templates. Use `generate_image` instead of `regenerate_post_image`, and do not pass `saveToLibrary`. These pages do not cover `generate_image` yet: `tools/list` has its schema. | | `post_already_published`, `already_published` | `false` | Some or all of the post is live, so it cannot be changed. `publishedPlatforms` lists what is live. Take those down with `unpublish_post`, or make a new post. | One refusal has no code of its own in the gateway today. When `update_post` tries to remove a destination that the provider is holding, the refusal arrives as `upstream_409`. Withdraw that destination with `unschedule_platform` first. New, in development: this refusal arrives as `platform_scheduled`, with `retryable: false`. The fix is the same: withdraw the destination with `unschedule_platform`, then remove it. ### Approval The approval gate runs before `schedule_post` and `publish_post_now`, and refuses the whole call. `reschedule_posts` runs it for each entry and reports a refusal in that entry’s `moves[].error`. `post_content_changed` comes from `approve_post` and `reject_post`. [Approval](https://docs.native.no/native-api/approval) explains the content digest behind these codes. | Code | `retryable` | What it means | | --- | --- | --- | | `approval_required` | `false` | New, in development: the workspace requires approval and there is none on record. Partner workspaces require it by default. Record one with `approve_post`. | | `approval_stale` | `false` | The content changed after it was approved. Read the post again, have it reviewed again, and approve it again. | | `approval_rejected` | `false` | The latest decision is a rejection. Editing the post does not clear it. Only a new `approve_post` does. | | `design_changed_after_approval` | `false` | New, in development: on a workspace that requires approval, the post’s design changed after it was approved, or publishing would change the approved pictures with a design re-render or a media repair. Nothing was sent. Read it again with `view_post`, have it reviewed again, and approve it again. | | `post_content_changed` | `false` | The post changed between `view_post` and the decision. Read it again and review it again. Never resend with a newer `contentDigest` the reviewer did not see. | ### Scheduling and publishing | Code | `retryable` | What it means | | --- | --- | --- | | `schedule_preflight_failed` | `false` | Native checked the post before it contacted any platform. Nothing was booked and the post is untouched. `blockedDestinations` names each problem. See [pre-flight codes](https://docs.native.no/native-api/errors#preflight-codes). | | `schedule_failed` | Varies | At least one destination was refused at the provider, and others may have booked. `refusedDestinations` and `scheduledDestinations` say which. See [when a schedule partly fails](https://docs.native.no/native-api/errors#schedule-failed). | | `already_scheduled`, `post_already_scheduled` | `false` | A publishing provider holds the post, so its schedule or its media cannot change this way. `lockedDestinations` lists each held destination. Withdraw each one with `unschedule_platform`, one call per entry. From `reject_post`, this code comes without `lockedDestinations`: read each destination’s `platformPostId` from `get_post_status`. | | `ayrshare_error` | `true` | The publishing provider refused or failed the request. Read `get_post_status` for the reason before you retry. | ## When a schedule partly fails Scheduling runs per destination underneath, so `schedule_failed` can leave some destinations booked. `scheduledDestinations` lists them, and `refusedDestinations` lists the ones that refused, each with a [code per destination](https://docs.native.no/native-api/errors#destination-codes). The envelope’s `retryable` is `false` when any refusing destination has `media_invalid`, `channel_not_connected`, `channel_needs_reauth` or `content_rejected`. It is `true` otherwise. A retry books only the destinations still outstanding. One exception: when the error carries neither `refusedDestinations` nor `scheduledDestinations`, Native could not say which destination refused, and `retryable` is `false`. Read `get_post_status` for the reason before you change anything. structuredContent.data: ```json { "ok": false, "error": { "code": "schedule_failed", "message": "A sentence Native wrote for people", "retryable": true, "refusedDestinations": [{ "platform": "INSTAGRAM", "code": "provider_unavailable" }], "scheduledDestinations": ["FACEBOOK"] } } ``` Here Facebook is booked and Instagram was refused because the provider was unavailable. Call `schedule_post` again unchanged, and it books only Instagram. ## Failures inside a result Some tools succeed as a whole and report what failed inside the result. Each failure there carries a `code` and a `message`, and all but the dry-run entries carry `retryable` too. - `publish_post_now`: `publishResults[].reason`, one per destination. Only `published` means a destination is live. - `reschedule_posts`: `moves[].error`, one per entry, with the same codes as `schedule_post`. The other entries still ran. - `get_post_batch_status`: `items[].error`, one per post. Item codes are `usage_limit_exceeded`, `ai_generation_failed`, `platform_not_connected`, `platform_not_supported`, `reddit_options_required`, `media_required`, `bad_request` and `internal_error`. - `get_post_status`: `platformResults[].error`, one per destination. - `get_generation_job`: `error`, when a render failed. - New, in development: `schedule_post` and `publish_post_now` with `dryRun: true` return `blocked[]`, what would stop each destination, with `platform`, `code` and `message`. Its codes include the pre-flight codes, `channel_not_connected` and `channel_needs_reauth`, and for `publish_post_now` the skip reasons too. Other codes can appear, such as `brand_inactive`. ### Codes per destination These codes describe why one destination failed. They appear in `platformResults[].error.code`, `publishResults[].reason.code` and `refusedDestinations[].code`. | Code | `retryable` | What to do | | --- | --- | --- | | `channel_not_connected` | `false` | Send the client a link from `create_connect_link`, then retry. | | `channel_needs_reauth` | `false` | The client has to reconnect the account. Send a link from `create_connect_link`, then retry. | | `permission_denied` | `false` | The account lacks a permission, for example a Page role. Reconnect with the right account. | | `rate_limited` | `true` | The platform is rate limiting. Retry later with backoff. | | `provider_timeout` | `true` | Check `get_post_status` first, because it may have gone through. Then retry. | | `provider_unavailable` | `true` | Retry later. | | `media_required` | `false` | Attach or generate media. | | `media_invalid` | `false` | Replace the image or video. The platform refused its aspect ratio, size, format or duration. | | `content_rejected` | `false` | The platform refused the content, for example on policy or length. Edit it, have it approved again, then retry. | | `duplicate_content` | `false` | The platform sees the post as a duplicate. Change the content, or drop the destination. | | `validation_failed` | `false` | Fix what the message names. Do not retry unchanged. | | `unknown_error` | `false` | Do not retry blindly: it may have published. Check `get_post_status`, and contact Native support if it is still unclear. | ### Pre-flight codes Before `schedule_post` contacts any platform, Native checks each destination against that platform’s rules. A destination that fails a rule appears in `blockedDestinations[]` with one of these codes. | Code | What it means | | --- | --- | | `caption_too_long` | The destination’s caption is over its limit. `characterCount` and `characterLimit` say by how much. | | `media_required` | The destination publishes nothing without a picture or a video. | | `image_aspect_invalid` | A picture is outside Instagram’s feed band of 0.75 to 1.91. | | `too_many_images` | The post has more pictures than the destination takes. `imageCount` and `imageLimit` say by how many. | | `video_required` | The destination takes exactly one video and no pictures, as YouTube does. | | `video_not_supported` | The destination publishes no video at all. | | `post_empty` | The post has neither a caption nor a picture. | | `reddit_subreddit_required` | A Reddit destination has no subreddit. | | `reddit_subreddit_not_found` | Reddit does not know the subreddit. | | `reddit_flair_required` | The subreddit requires a flair and the post has none. The flair is chosen in the Native app. | | `reddit_title_too_long` | The Reddit title is over Reddit’s limit. `characterCount` and `characterLimit` can say by how much. | Fix every destination named, or leave those destinations out with `platforms`, before you call again. Unchanged, the call fails the same way. ### Publish results `publish_post_now` answers with `publishResults[]`, one per destination. A destination that did not publish carries a `reason`: | Code | Status | What it means | | --- | --- | --- | | `publishing_in_progress` | `pending` | The platform accepted it and is finishing in the background. Instagram does this routinely. Do not retry. Poll `get_post_status`. | | `already_published` | `skipped` | Already live. Publishing again would duplicate it. | | `held_by_provider` | `skipped` | Booked for a scheduled time. Withdraw it with `unschedule_platform` first if it should go out now. | | `stuck_at_provider` | `skipped` | The provider took it and never reported back. Retrying could duplicate it. Contact Native support. | | `story_partially_published` | `skipped` | Some story frames are live. A retry would duplicate them. | | `not_requested` | `skipped` | Not named in `platforms`. | | A [code per destination](https://docs.native.no/native-api/errors#destination-codes) | `failed` | The destination failed. Follow its `retryable`. | ### Image render codes When an image render fails, `get_generation_job` reports `status: "failed"` and an `error` with one of these codes. To retry a failed render, call `regenerate_post_image` again. Never do that while the job is still running. | Code | `retryable` | What it means | | --- | --- | --- | | `content_rejected` | `false` | The image provider refused the request, for example on content policy. Change the prompt. | | `quota_exhausted` | `false` | The image provider refused the render on quota or billing. | | `provider_timeout` | `true` | The image provider timed out. | | `provider_unavailable` | `true` | The image provider was unavailable or rate limiting. | | `generation_failed` | `true` | The render failed for another reason. | ## Build and connect failures Two flows report failure outside a tool result: a profile build that runs in the background, and an account connection that ends in the client’s browser. Both are new and in development. ### Profile build failures New, in development: when a `create_profile` build fails, `get_profile` reports `onboarding.status: "failed"` and a code in `onboarding.error`: `website_unreachable`, `analysis_failed`, `enrichment_failed`, `build_not_queued` or `build_interrupted`. Treat the list as open: more codes can appear. `create_profile` answers `status: "failed"` only when it could not start the build, in practice with `build_not_queued`. To retry a failed build, call `create_profile` again with the same `externalId`. The build restarts where it stopped and the call answers `building`, so read the code from `get_profile` before you retry. ### Website refusals New, in development: when `create_profile` refuses the website with `invalid_website_url`, nothing is created and `websiteRefusal` says why: | `websiteRefusal` | What it means | | --- | --- | | `BLOCKED_DOMAIN` | A social media or platform address, not the client’s own website. | | `URL_NOT_REACHABLE` | The address does not resolve, or refused the connection. | | `URL_UNSAFE` | The address is flagged as harmful. | | `URL_NOT_ALLOWED` | The address points at a private address. | ### Connect failures New, in development: a headless connect link sends the browser back to your `returnUrl` with `status=failed` and an `error` code, for example `oauth_denied`, `link_expired`, `account_in_another_workspace` or `connection_failed`. Mint a fresh link to retry. Do not poll: a failed connect does not recover on its own. ## Errors outside the envelope These answers come back as an HTTP status before any tool runs, so they carry no envelope. | Status | When | What to do | | --- | --- | --- | | `401` | The token is missing, expired or invalid. The response carries a `WWW-Authenticate` challenge. | Fetch a new token. See [Authentication](https://docs.native.no/authentication). | | `403` `insufficient_scope` | A tool that changes data was called with a token that lacks `mcp:write`. | Request the token with `mcp:read mcp:write`. | | `429` | Too many requests from one IP address in a minute. The edge refuses the request before it reaches a tool. | Slow down and back off. See [Limits](https://docs.native.no/native-api/limits#request-rate). | An argument that breaks a tool’s input schema is rejected by the MCP SDK before the tool runs. You get the SDK’s validation error, not the envelope. Check your call against the `inputSchema` that `tools/list` returns. ## Retrying - Respect `retryable`. Never send a call again after `retryable: false` without changing something first. - After a timeout or a dropped connection on a write, read the current state before you retry, with `get_post_status`, `get_post_batch_status` or `view_post`. A lost response does not prove that nothing changed. - `publish_post_now` is safe to call again. It retries only the destinations that failed and never dispatches a published one again. The skip reasons `stuck_at_provider` and `story_partially_published` need Native support or a new post, not a retry. - A partly refused `reschedule_posts` call is not untouched: the other entries ran. Do not resend the whole list. - Never call `regenerate_post_image` again to retry a render that is still running. Poll its job with `get_generation_job`. - Back off exponentially, with jitter, on errors that are retryable. Stop on errors that are not. ### Repeated calls are not deduplicated For each tool call, the gateway attaches an `Idempotency-Key` to every write it makes to Native, and Native replays the stored result for a repeated key. The transport keeps no state between calls, though, so each tool call gets a fresh random part in its key. **If you send the same tool call again, it is a new attempt, and Native does not deduplicate it.** Caller-supplied idempotency keys are planned, not available. Until then, check the current state before you retry any write that creates or publishes something: `create_posts`, `publish_post_now` and `schedule_post`. New, in development: `create_profile` is idempotent on its own terms. A repeat with the same `externalId` answers with the profile the first call made, with `created: false`. ### Codes you do not recognise New codes can appear at any time for new failure cases. Treat a code you do not recognise as its `retryable` flag says. ## Example A call with the official MCP SDK client, branching on the code: TypeScript: ```ts const result = await client.callTool( { name: "schedule_post", arguments: { id: postId, scheduledAt: "2026-11-02T09:00:00+01:00" }, }, undefined, { timeout: 150_000 }, ); const data = (result.structuredContent as { data: any } | undefined)?.data; if (result.isError) { const error = data.error as { code: string; message: string; retryable: boolean; blockedDestinations?: { platform: string; code: string }[]; }; switch (error.code) { case "schedule_preflight_failed": // Nothing was booked. Fix every destination it names, then call again. for (const blocked of error.blockedDestinations ?? []) { console.warn(blocked.platform, blocked.code); } break; case "approval_stale": // Read the post again with view_post and have it reviewed again. break; default: if (error.retryable) { // Read the current state first, then retry with backoff. } else { // Change something before calling again. Never parse error.message. } } } ``` --- # Limits and versions Source: https://docs.native.no/native-api/limits > **Preview.** The Native API is not live yet. This page describes the planned interface; names and fields can change before access opens. Size and rate limits, timeouts, versions, formats, image URLs and polling intervals for the Native API, and what is planned but not available. > **Not live for partners yet.** Partner access to the Native API is not live yet. Partner sign-in with client credentials is in development, so a partner backend cannot call these tools today. The limits on this page are the ones that apply today to signed-in Native users. Anything marked **in development** is not available. This page collects the numbers that bound a call: how big, how often and how long. It also covers how versions will work, the formats each destination takes, how long image URLs last and how often to poll. ## Size and count limits | Limit | Value | | --- | --- | | Posts per `create_posts` call | 1 to 20. | | Library images (`mediaIds`) per `create_posts` call | Up to 10. | | Entries per `reschedule_posts` call | 1 to 25. | | Reference images per `regenerate_post_image` call | Up to 9 Native library URLs. The input schema allows 10, but Native refuses a list of more than 9 with `bad_request`, because the slide being redrawn takes the tenth slot. | | Request body | Up to 12 MiB. | | Image upload as base64 (`dataBase64`) | Up to 11 MiB of characters, which is an image of about 8 MiB. Native caps the image itself at 8 MiB. | | Caption length | Per destination, as `characterLimit` on each destination: 2,200 for Instagram and 63,206 for Facebook. Stories use their base platform’s limit. A limit of 0 means no limit is recorded. A caption over its limit is refused at scheduling. | | Access token lifetime | One hour at most. See [Authentication](https://docs.native.no/authentication). | ## Request rate Today the edge in front of `https://mcp.native.no/mcp` allows 120 requests a minute from one IP address, across every tool. It is not a quota per partner or per client. Past it, the edge answers HTTP 429 before the request reaches a tool, so there is no error envelope. Slow down and back off. Every HTTP request to the endpoint counts, including the ones the SDK sends when it connects. Keep one connected client for as long as its token is valid, rather than connecting for each call. Two other rate limits come back as codes. `upstream_429` means Native itself is rate limiting the call. `rate_limited` on a destination means the social platform refused it. Both are retryable after a wait. See [Errors](https://docs.native.no/native-api/errors). ## Timeouts - Calls normally return within seconds. - Writes that contact platforms, `schedule_post` and `publish_post_now`, can take much longer. The gateway waits up to **120 seconds** for Native, then answers `upstream_timeout`. The work may still be running, so read the current state before you retry. - Set your client’s request timeout above 120 seconds, for example 150 seconds. The MCP SDK’s default is lower, and a slow write can time out on your side after it has already succeeded on Native’s. - `get_generation_job` with the default `wait: true` holds the call for up to about 24 seconds while the job settles. TypeScript: ```ts const result = await client.callTool( { name: "publish_post_now", arguments: { id: postId } }, undefined, { timeout: 150_000 }, // above the 120 s the gateway waits for Native ); ``` Generation is always asynchronous. `create_posts` returns a `batchId` and `regenerate_post_image` returns a `jobId`. Poll them as described under [polling](https://docs.native.no/native-api/limits#polling). Do not hold a call open. ## Plan allowance Each workspace has an AI budget, counted in Native tokens, that comes with its plan. For a profile made with `create_profile` (in development), that is the partner’s plan. When the budget runs out, calls that use AI fail with `usage_limit_exceeded`. `retryable` is `false`, and `resetDate` says when the budget refills. Posts in a batch that hit it carry the same code. A workspace with no active subscription gets `subscription_required`. ## Versions > **In development.** Versioned endpoints are in development. Today there is one endpoint, `https://mcp.native.no/mcp`, and no version to pin. This section describes how versions will work when they ship. - `https://mcp.native.no/mcp` will always serve the latest version. - `https://mcp.native.no/mcp/v1` will serve version 1, pinned, beside it. Partners should call the versioned endpoint. - Every version takes the same token. The audience stays `https://mcp.native.no/mcp`, so you switch versions by changing only the URL. - The `initialize` and `server/discover` results report the version in `capabilities.experimental["no.native/partner-api"]`, as `{ version, latestVersion }`. When `latestVersion` is greater than `version`, a newer contract has shipped: read the changelog. initialize result, excerpt: ```json { "capabilities": { "experimental": { "no.native/partner-api": { "version": 1, "latestVersion": 1 } } } } ``` ### Notice and overlap - **Notice:** Native will announce a breaking change at least 2 weeks before it ships. - **Overlap:** the previous version will keep running for at least 4 weeks after the new one ships. After it shuts down, its versioned endpoint will answer HTTP 404. - **Changelog:** every change will be listed with its date and whether it is breaking. ### Breaking changes These will ship only as a new version: - Removing or renaming a tool, an input field or an output field. - Tightening input: a new required field, a narrower type or range, or a value that used to be accepted and is now refused. - Removing an enum value from output, or changing what an existing field, status or error code means. - Changing which error code a given failure returns, or turning a success into an error. - Changing a default. ### Additive changes These can ship at any time, without a new version: - New tools. - New optional input fields. - New output fields. - New error codes for new failure cases. - New enum values in output. - New `blocked` and `reason` codes. Build your client to tolerate them. Ignore fields you do not use, and treat a code you do not recognise as its `retryable` flag says. ## Formats The destinations that matter most to partners, as names from Native’s `Platform` enum: | Destination | Kind | Caption | Notes | | --- | --- | --- | --- | | `INSTAGRAM` | Feed | Yes, up to 2,200 characters. | Images must sit within Instagram’s aspect band of 0.75 to 1.91, or pre-flight refuses them with `image_aspect_invalid`. Carousels are supported. | | `INSTAGRAM_STORIES` | Story | **Published without a caption.** | Needs media: one image or one video. | | `FACEBOOK` | Feed | Yes, up to 63,206 characters. | | | `FACEBOOK_STORIES` | Story | **Published without a caption.** | Needs media: one image or one video. | Stories use the brand’s Instagram or Facebook connection. They never appear as channels of their own. `get_profile` lists the story destinations a brand can use in `storyDestinations`. A post whose only destinations are stories, and which has no media, is refused with `media_required`. ### Aspect ratios - Story targets are 9:16. Feed targets are 4:5. - `regenerate_post_image` renders at the ratio of the first destination that holds that slot: 9:16 for `INSTAGRAM_STORIES`, `FACEBOOK_STORIES` and `TIKTOK`, and 4:5 for `INSTAGRAM`, `FACEBOOK`, `LINKEDIN` and `TWITTER`. 4:5 is also the default. On a post with only story destinations, a regenerated image is 9:16. - Native has not yet confirmed that the first render of a stories-only post from `create_posts` or `create_draft` comes out 9:16. Until it does, check the dimensions of the image `view_post` returns. - Every destination on a post shares the same image in each slot, and a 9:16 image fails Instagram feed pre-flight. Put feed and story content in separate posts. Do not mix `INSTAGRAM` and `INSTAGRAM_STORIES` on one post. ### Times Send times as ISO 8601, with `Z` or a numeric offset, such as `2026-11-02T09:00:00+01:00`. The gateway converts them to UTC. ### Destination names Wherever a post’s destinations appear, in `platforms`, `destinations[].platform` or `platformResults[].platform`, they are uppercase `Platform` enum names such as `INSTAGRAM`. `get_profile` is the exception. Its `channels[].platform` and `storyDestinations` use lowercase channel names such as `instagram` and `instagram_stories`. ## Asset URLs Image URLs come back from `view_post` (`imageUrls`, `destinations[].imageUrls`), `get_generation_job` (`imageUrls`, `outputs[].imageUrl`) and `regenerate_post_image` (`images[].url`). They are plain public Google Cloud Storage URLs, of the form `https://storage.googleapis.com//...`. - They are unsigned and do not expire. You can show them directly or copy them. Anyone who has a URL can open the image. - A URL stops resolving only when its stored file is deleted, for example when `delete_profile` (in development) erases a client’s images. - Replacing or removing an image on a post does not guarantee that the old URL stops working. Native keeps image history so edits can be restored, and library images outlive their posts. Do not count on a post edit to revoke access to an old URL. - A changed image always has a new URL. The URL is part of the content digest, so a changed image also means a new `contentDigest`. ## Polling Native has no webhooks yet, so you poll. These are the intervals to use: | What | Call | Interval | Done when | Notes | | --- | --- | --- | --- | --- | | Profile build (in development) | `get_profile` | About every 60 seconds. | `onboarding.status` is `ready` or `failed`. | Typically 10 to 30 minutes. On `failed`, read `onboarding.error` and retry with `create_profile` and the same `externalId`. | | Offboarding (in development) | `deactivate_profile` or `delete_profile`, called again with the same arguments | Every 30 seconds. | `status` is `deactivated` or `deleted`. | Usually within minutes. Repeating the call is safe. | | Account connected | `get_profile` | Once the client has finished connecting, then every 10 seconds for up to 2 minutes. With a headless link (in development), start when `returnUrl` reports `status=connected`, or after `complete_connect`. | The channel has `health: "connected"` and `readiness.canPublish` is `true`. | `status=failed` needs a fresh link, not polling. | | Post generation | `get_post_batch_status` | Every 5 seconds for the first minute, then every 15 seconds, then at most every 30 seconds. | `settled` is `true` **and** `imagesPendingCount` is 0. | `settled` covers copy only, and images can still be rendering. Give up and alert after about 15 minutes. Failed items carry `error.code`. | | Image render | `get_generation_job` | Call it with the default `wait: true`, since each call already waits about 24 seconds. Or use `wait: false` every 5 seconds. | `status` is `completed` or `failed`. | Never call `regenerate_post_image` again to retry a render that is still running. Template renders (in development) take a few minutes, so expect several polls. | | Scheduled publish | `get_post_status` | Once, a few minutes after `scheduledAt`. Then every 5 minutes for 30 minutes. | Every destination’s `state` is `published` or `failed`. | `stuck` means overdue with no outcome: stop polling and escalate. While a destination reads `scheduled`, keep polling. `pending` means no provider holds it, so it will not go out on its own: schedule or publish it again. | | After `publish_post_now` | `get_post_status` | Every 10 seconds for 2 minutes, then every 60 seconds for 10 minutes. | Every destination is `published` or `failed`. | Only the destinations that came back `pending` (`publishing_in_progress`) in `publishResults` need polling. They read `scheduled` here until they land. | | Channel health | `get_profile` | Daily, and before scheduling. | No end: keep checking. | Act on `needs_reauth` and `disconnected`, and on `expiring_soon` (`tokenExpiresAt`), before publishing breaks. | For every poll, back off exponentially with jitter on errors that are retryable, and stop on errors that are not. ## Planned, not available These are planned. None of them is available yet. - **Webhooks** are not available yet. They are planned for generation done, approval changes, publish success or failure, and account disconnected. Until then, poll as described under [polling](https://docs.native.no/native-api/limits#polling). - **Per-client monthly usage reporting** is planned, as a partner usage endpoint. It is not available yet. - **Caller-supplied idempotency keys** are planned, not available. Until then, sending the same tool call again is a new attempt, so check the current state before you retry a write that creates or publishes something. See [repeated calls](https://docs.native.no/native-api/errors#repeated-calls). --- # Authentication Source: https://docs.native.no/authentication > **Preview.** These APIs are not live yet. This page describes the planned interface; names and fields can change before access opens. One login for the Norn API and the Native API: a client ID and secret for your backend, exchanged for an access token that lasts at most an hour. > **Not live yet.** Machine-to-machine access is still being built. Credentials are issued when your access opens. Until then, the Native MCP server only accepts people signing in through an assistant, as on the [Claude connector](https://native.no/claude-connector) page. ## One login for both APIs When your access opens, Native gives your team one machine-to-machine application, with a `client_id` and a `client_secret`. The same pair signs you in to the Norn API and the Native API. Your backend trades it for a short-lived access token and sends that token with every call. Keep the secret on your server. Never put it in a browser, a mobile app or a repository. If it leaks, write to [hei@native.no](mailto:hei@native.no) and we will replace it. ## Get an access token Use the OAuth client-credentials grant. Name the API you want a token for in `audience`: | API | `audience` | `scope` | Status | | --- | --- | --- | --- | | [Native API](https://docs.native.no/native-api) | `https://mcp.native.no/mcp` | `mcp:read mcp:write` | In development | | [Norn API](https://docs.native.no/norn) | `https://api.native.no/v1` | Set when your access opens | Planned | cURL: ```bash curl -X POST https://auth.native.no/oauth/token \ -d grant_type=client_credentials \ -d client_id="$NATIVE_CLIENT_ID" \ -d client_secret="$NATIVE_CLIENT_SECRET" \ -d audience=https://mcp.native.no/mcp \ -d scope="mcp:read mcp:write" ``` TypeScript: ```ts const TOKEN_URL = 'https://auth.native.no/oauth/token' // Cache one token per API and fetch a new one a minute before it runs out. // Calls that arrive while a token is on its way share that request. const cache = new Map() const pending = new Map>() export async function accessToken(audience: string, scope: string): Promise { const key = `${audience} ${scope}` const hit = cache.get(key) if (hit && hit.expiresAt - Date.now() > 60_000) return hit.token let request = pending.get(key) if (!request) { request = requestToken(audience, scope) .then((entry) => { cache.set(key, entry) return entry.token }) .finally(() => pending.delete(key)) pending.set(key, request) } return request } async function requestToken(audience: string, scope: string) { const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.NATIVE_CLIENT_ID, client_secret: process.env.NATIVE_CLIENT_SECRET, audience, scope, }), }) if (!res.ok) throw new Error(`Token request failed: ${res.status}`) const body = (await res.json()) as { access_token: string; expires_in: number } return { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 } } const token = await accessToken('https://mcp.native.no/mcp', 'mcp:read mcp:write') ``` Python: ```python import os import requests res = requests.post( "https://auth.native.no/oauth/token", data={ "grant_type": "client_credentials", "client_id": os.environ["NATIVE_CLIENT_ID"], "client_secret": os.environ["NATIVE_CLIENT_SECRET"], "audience": "https://mcp.native.no/mcp", "scope": "mcp:read mcp:write", }, timeout=30, ) res.raise_for_status() token = res.json()["access_token"] ``` Response: ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…", "token_type": "Bearer", "expires_in": 3600, "scope": "mcp:read mcp:write" } ``` - The audience must match the API exactly. A token for one API does not work on the other: fetch one token per API you call. - A token lasts at most one hour, and the Native API refuses a token that would last longer. Cache it and fetch a new one shortly before `expires_in` runs out. - Every Native API tool needs `mcp:read`. Tools that change something also need `mcp:write`. ## Sandbox When your access opens, you can also ask for credentials on Native’s development environment, for trying calls without touching real customers. They use the same flow, with a token endpoint and an MCP address of their own that we send with them. ## Use the token Send the token as a bearer token on every request: HTTP: ```http Authorization: Bearer ``` The Native API is MCP over Streamable HTTP, so the token goes on the transport when you create the MCP client. Make a new client when you fetch a new token. [The Native API overview](https://docs.native.no/native-api) shows the client. ## When a token is refused | What you get | Why | What to do | | --- | --- | --- | | HTTP `401` with a `WWW-Authenticate` challenge | The token is missing, expired or not valid for this API. | Fetch a new token for the right `audience`. | | HTTP `403`, `insufficient_scope` | The token lacks `mcp:write` for a tool that changes something. | Ask for `mcp:read mcp:write` in `scope`. | | Tool error `partner_not_found` | The token is valid, but Native has no partner set up for your client ID. | Write to [hei@native.no](mailto:hei@native.no). |