Native APIGet started
PreviewFrom onboarding to offboarding
The calls a partner backend makes for one client, in order: onboard, set the look, connect accounts, generate, approve, publish, track and offboard.
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, Branding, Social accounts, Generating posts, Editing posts, Approval and 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 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_inruns out. - The token is set when the transport is made. Make a new client with each new token.
TypeScript
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<Client> } | 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<Client> {
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<Client> {
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<string, unknown>): Promise<any> {
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 lists every code.
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.
Onboard a client
#TOOLcreate_profileIn developmentSend the client’s website, its business name and your own id for the client.
create_profilemakes a workspace of its own for the client, on your plan, with no browser and no checkout.Arguments
{ "websiteUrl": "https://pipedream.example", "name": "Pipe Dream Plumbing", "externalId": "client-1042" }Native answers at once with the
brandIdthat 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
{ "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "companyId": "c51c8e77-4123-4461-a185-c5e8475174a9", "status": "building", "created": true }externalIdmakes the call safe to repeat. A second call with the sameexternalIdanswers with the profile the first call made, withcreated: false, whatever else it sends.allowTeamPhotosis 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.Wait until the profile is ready
#TOOLget_profilePreviewA build typically takes 10 to 30 minutes. Poll
get_profilewith thebrandIdabout once a minute, untilonboarding.statusisreadyorfailed. Theonboardingfield is in development. It appears only on a profile thatcreate_profilemade.Result while the profile builds, shortened
{ "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 withprofile_not_ready:create_posts,generate_caption,generate_image,regenerate_post_image, ad generation, andcreate_draftwhen it is sent apromptorincludeImage: true. That includes a profile whose build has failed. Acreate_draftwith your own caption is not refused.When a build fails,
onboarding.errorsays why:website_unreachable,analysis_failed,enrichment_failed,build_not_queuedorbuild_interrupted. Treat the list as open, since more codes can come. To retry, callcreate_profileagain with the sameexternalId. The build restarts where it stopped.Set the brand’s look
#TOOLget_brand_styleIn developmentTOOLupdate_brand_styleIn developmentTOOLupload_brand_logoIn developmentThe 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_styleadds 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 withfont_not_found, and no role changes.Arguments
{ "brandId": "1f461fa4-a41c-4efd-81ba-2946ca61d479", "colors": { "background": "#F7F4EE", "accent": "#1F5FAD", "text": "#1B1D21", "secondary": "auto" }, "fonts": { "heading": "Fraunces", "body": "Inter" } }upload_brand_logosaves 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.Connect the client’s social accounts
#TOOLcreate_connect_linkPreviewTOOLget_connect_optionsIn developmentTOOLcomplete_connectIn developmentcreate_connect_linkmints 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.
platformmakes the link headless: it goes straight to the platform’s own login and consent screen. A headless link needsreturnUrl, an https URL of yours. When the client is done, the browser comes back to it.Arguments for a headless link
{ "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 withoutplatformandreturnUrlfor a link to Native’s connect page.Native adds
statusto yourreturnUrl, and replaces anystatus,platform,connectTokenorerroralready on it:statusAlso on the URL What to do connectedplatformConfirm with get_profile.selection_requiredplatform,connectTokenThe client manages several Facebook Pages, LinkedIn organisations, Pinterest boards or Google Business locations. Pass connectTokentoget_connect_options, let the client choose, then callcomplete_connectwith their choice.failedplatform,errorerroris a code such asoauth_denied,link_expiredoraccount_in_another_workspace. Mint a fresh link to retry.complete_connectreplaces any account the brand already had on that platform. Call it only with the choice the client made. TheconnectTokenlasts about 10 minutes. After that, both tools fail withconnect_session_expired, and the client needs a fresh link.Then confirm the connection. Call
get_profileonce, then every 10 seconds for up to 2 minutes, until the channel’shealthisconnectedandreadiness.canPublishistrue. Afailedstatus needs a fresh link, not more polling.Generate posts
#TOOLcreate_postsPreviewTOOLget_post_batch_statusPreviewcreate_poststurns one brief into 1 to 20 draft posts. Each takes a different angle and gets copy written for each destination. Leave outplatformsto use the brand’s defaults. Destinations here are uppercase names such asINSTAGRAM.get_profileandcreate_connect_linkuse lowercase names such asinstagram.Arguments
{ "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. Pollget_post_batch_statusevery 5 seconds for the first minute, then every 15 seconds, growing to no more than 30. The batch is finished whensettledistrueandimagesPendingCountis 0.settledalone covers the copy: pictures can still be rendering.TypeScript
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 anerrorwith acode. If a batch has not finished after about 15 minutes, stop and raise an alert.Created items will carry their destinations,
imageUrls,contentDigestandapproval(in development), so you can show and approve a post without reading it again. Until then, read each created post withview_post, passing itspostIdasid.scheduledAtoncreate_postsonly 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, andresetDatesays when they refill.Edit posts
#TOOLupdate_postPreviewTOOLgenerate_captionPreviewTOOLregenerate_post_imagePreviewTOOLget_generation_jobPreviewupdate_postoverwrites the caption, the first comment or the destinations. Setplatformto change one destination’s caption or first comment only. No history is kept: when the call overwrites something,replacedin the result holds the previous values.generate_captionrewrites a caption in the brand’s voice and replaces the current one.instructionssteers the rewrite.regenerate_post_imageredraws one picture and leaves the other slides alone. It answers at once withimageGeneration.jobId. Pollget_generation_jobwith it until the job iscompletedorfailed.TypeScript
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_imageagain to retry a render: poll the job you have. While a render is running, a second call is refused withimage_generation_in_progress, which carries the running render’sjobId. 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_imagewill be refused withtemplate_rendering. Usegenerate_imagethere: it redraws the post’s whole design, replaces all its pictures and keeps the slide text exact. These pages do not covergenerate_imageyet:tools/listhas 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 withapproval_stale. After editing, read the post again withview_postand have that version reviewed. A published post cannot be edited: edits are refused withpost_already_published.Get the client’s approval
#TOOLview_postPreviewTOOLapprove_postPreviewTOOLreject_postPreviewShow the client the post as
view_postreturns it. Each entry indestinationsholds the caption, first comment, title, pictures and publishing options that will publish on that channel. Keep thecontentDigestfrom that same read.When the client approves, call
approve_postwith that digest asexpectedDigest. Name the person who approved inreviewerNameandreviewerEmail. Without them, Native records the caller as the reviewer.TypeScript
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_postwith theirreason. While the latest decision is a rejection, scheduling and publishing refuse the post withapproval_rejected, even after edits. Only a newapprove_postclears 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.
Schedule or publish
#TOOLschedule_postPreviewTOOLreschedule_postsPreviewTOOLpublish_post_nowPreviewTOOLunschedule_platformPreviewschedule_postbooks 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_postsbooks or moves up to 25 posts in one call.publish_post_nowpublishes at once.Arguments
{ "id": "faa2595d-41e8-4645-8332-4f4f9df9e9b8", "scheduledAt": "2026-11-02T09:00:00-05:00" }Times are ISO 8601, with
Zor an offset. The gateway converts them to UTC. Report a post as scheduled only whenscheduleConfirmedin the result istrue.Before it contacts any platform,
schedule_postchecks 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 withschedule_preflight_failed. Nothing is booked, andblockedDestinationssays what to fix.A dry run is in development. With
dryRun: true,schedule_postandpublish_post_nowrun every check and book or publish nothing.blockedlists the destinations that would fail. An empty list is not a guarantee: a platform can still refuse when the post goes out.reschedule_postsruns each entry on its own. A refused entry shows its error inmoves[].error, and the others still run. Do not resend the whole list.publish_post_nowis safe to call again. It retries only the destinations that failed and never sends a published one twice. A destination that comes backpendingwithpublishing_in_progressis still finishing at the platform, as Instagram routinely does. Do not retry it: pollget_post_status.To pull one destination back from its booking, call
unschedule_platformwith itsplatformPostId, whichget_post_statusreturns. A post with three destinations takes three calls. The destination keeps its date as a draft.Track what happens
#TOOLget_post_statusPreviewTOOLget_profilePreviewNative does not retry a failed publish by itself, and there are no webhooks yet. Poll
get_post_status. It reports each destination’sstate, its liveurland, on a failure, anerrorwith acodeandretryable.After How often Stop when A scheduled time Once a few minutes after scheduledAt, then every 5 minutes for 30 minutesEvery destination is publishedorfailedpublish_post_nowEvery 10 seconds for 2 minutes, then every minute for 10 minutes Every destination is publishedorfailedA destination in
stuckwas 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_reauthorchannel_not_connected, send the client a fresh connect link, then retry. For other codes, followretryable.Check channel health with
get_profileevery day and before you schedule. Act onneeds_reauthanddisconnected. Act onexpiring_soontoo: the channel still publishes, but only until its token runs out.Offboard the client
#TOOLdeactivate_profileIn developmentTOOLdelete_profileIn developmentdeactivate_profileends 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_profileerases 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
{ "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
statusisdeactivatedordeleted. That usually takes minutes. Repeating either call is always safe.Both refuse with
stripe_billed_workspacewhen 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/v1endpoint 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
#The Native API is not open yet
Partner access is being built now. Tell us what you would build, and we will be in touch when it opens.