Native API· The flow

Native APIGet started

Preview

From 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_in runs 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.

  1. Onboard a client

    #
    TOOLcreate_profileIn 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

    {
      "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

    {
      "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

    #
    TOOLget_profilePreview

    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

    {
      "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

    #
    TOOLget_brand_styleIn development
    TOOLupdate_brand_styleIn development
    TOOLupload_brand_logoIn 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

    {
      "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.

  4. Connect the client’s social accounts

    #
    TOOLcreate_connect_linkPreview
    TOOLget_connect_optionsIn development
    TOOLcomplete_connectIn 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

    {
      "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:

    statusAlso on the URLWhat to do
    connectedplatformConfirm with get_profile.
    selection_requiredplatform, connectTokenThe 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.
    failedplatform, errorerror 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

    #
    TOOLcreate_postsPreview
    TOOLget_post_batch_statusPreview

    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

    {
      "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

    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

    #
    TOOLupdate_postPreview
    TOOLgenerate_captionPreview
    TOOLregenerate_post_imagePreview
    TOOLget_generation_jobPreview

    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

    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

    #
    TOOLview_postPreview
    TOOLapprove_postPreview
    TOOLreject_postPreview

    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

    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

    #
    TOOLschedule_postPreview
    TOOLreschedule_postsPreview
    TOOLpublish_post_nowPreview
    TOOLunschedule_platformPreview

    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

    {
      "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.

  9. Track what happens

    #
    TOOLget_post_statusPreview
    TOOLget_profilePreview

    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.

    AfterHow oftenStop when
    A scheduled timeOnce a few minutes after scheduledAt, then every 5 minutes for 30 minutesEvery destination is published or failed
    publish_post_nowEvery 10 seconds for 2 minutes, then every minute for 10 minutesEvery 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

    #
    TOOLdeactivate_profileIn development
    TOOLdelete_profileIn 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

    {
      "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

#
WaitHow longHow to follow it
Profile buildTypically 10 to 30 minutesget_profile, about once a minute
Connect linkExpires about 10 minutes after you mint it. A connectToken lasts about as longMint a fresh link when it runs out
Post generationRaise an alert after about 15 minutesget_post_batch_status, every 5 to 30 seconds
Image renderA template render (in development) takes a few minutesget_generation_job, which waits up to about 24 seconds per call
One callUp to 120 seconds for a write that contacts a platformA client timeout above that, such as 150 seconds
OffboardingUsually minutesThe 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

#

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.

Contact sales