Blog

Build an Instagram API Post With an Image: OAuth to Final Status

Build a tenant-safe Instagram API post with Kavenio: connect an account, verify readiness, publish one idempotent image, and track the final result.

Kavenio Engineering

Related implementation guides

  1. Why Instagram Publishing Fails Before Upload: An Account and Permission Checklist
  2. Instagram Content Publishing API: Accounts, Permissions, and Containers

An Instagram API post flow is not finished when your product can open an OAuth window. It is finished when the right customer account receives the right image once, and your application records what happened.

This guide builds that path with Kavenio. The result is a small server-side TypeScript workflow that:

  1. starts an Instagram connection for one customer profile;
  2. verifies the connected account before publishing;
  3. creates an image post with a retry-safe request ID; and
  4. waits for a terminal post and target status.

It is for SaaS developers who already have users or tenants in their own database. It does not replace Instagram's eligibility rules. Instagram still requires a Business or Creator account, as described in Meta's Instagram Platform overview.

Keep the browser out of the publishing path

Use three boundaries:

BoundaryResponsibilityData it may keep
BrowserSend the user to the authorization URL and show the connection resultKavenio account ID, never the Kavenio API key
Application serverStart connections, persist account ownership, enqueue publish workTenant/profile ID, account ID, post command ID
WorkerCheck readiness, create the post, and reconcile its final stateKavenio post ID and provider result

The Kavenio API key stays on the server. A stable profileId from your product keeps the connection tied to the correct customer even when several tenants connect Instagram accounts at the same time.

Initialize one client in your server or worker process:

import { KavenioClient } from "@kavenio/sdk";

export const kavenio = new KavenioClient({
  authToken: process.env.KAVENIO_API_KEY,
});

Fail application startup if KAVENIO_API_KEY is missing. Passing an undefined token only postpones the failure until the first API request.

1. Start OAuth with your own profile key

Create the authorization URL on the server and return only that URL to the browser:

export async function beginInstagramConnect(profileId: string) {
  const connection = await kavenio.connect.begin({
    platform: "instagram",
    profileId,
    returnTo: "https://app.example.com/settings/social-accounts",
  });

  return connection.authorizationUrl;
}

Open the URL for the user. Kavenio receives Meta's callback, exchanges the authorization code, stores the encrypted credentials, and then redirects to your returnTo URL. The redirect query includes the Kavenio accountId, profileId, and platform.

Do not treat those query parameters as authorization to move an account between tenants. Confirm that the returned profileId belongs to the signed-in user, then store the Kavenio accountId under that profile. For a durable server-to-server record, subscribe to the account.connected webhook described in the connection guide.

Kavenio's direct Instagram flow verifies that Meta returned a Business or Creator identity. If the connection itself fails, use the pre-upload account and permission checklist before inspecting the image.

2. Check the stored account before creating a post

Connection state can change after OAuth. A person can revoke access, a required destination can disappear, or the account can need reconnection. Check the account immediately before an on-demand publish, or near execution time for a scheduled one.

export async function assertInstagramReady(accountId: string) {
  const health = await kavenio.accounts.health(accountId);

  if (health.account.platform !== "instagram") {
    throw new Error(`Account ${accountId} is not an Instagram account.`);
  }

  if (!health.healthy || health.readiness?.ready === false) {
    const details = health.issues
      .map((issue) => `${issue.code}: ${issue.message}`)
      .join("; ");

    throw new Error(details || "Instagram account is not ready to publish.");
  }

  return health.account;
}

This is an execution gate, not a background dashboard decoration. If it fails, stop the publish command and show the customer the reported reconnect or setup action. Sending the same media request to an unhealthy account adds noise but does not repair the connection.

3. Create one Instagram API post with one request ID

The image must be available at a public HTTPS URL that Meta can fetch. Do not send a browser-only URL, a local development URL, or a short-lived signed URL that may expire while the provider is processing it.

type PublishInstagramImageInput = {
  accountId: string;
  caption: string;
  imageUrl: string;
  profileId: string;
  requestId: string;
};

export async function createInstagramImagePost(
  input: PublishInstagramImageInput,
) {
  const imageUrl = new URL(input.imageUrl);

  if (imageUrl.protocol !== "https:") {
    throw new Error("Instagram media must use a public HTTPS URL.");
  }

  await assertInstagramReady(input.accountId);

  return kavenio.posts.create(
    {
      profileId: input.profileId,
      content: input.caption,
      mediaItems: [
        {
          type: "image",
          url: imageUrl.toString(),
          mimeType: "image/jpeg",
        },
      ],
      platforms: [
        {
          platform: "instagram",
          accountId: input.accountId,
          platformSpecificData: { contentType: "feed" },
        },
      ],
      publishNow: true,
    },
    { requestId: input.requestId },
  );
}

Generate requestId once when your application accepts the publish command, persist it with that command, and reuse it if the worker retries. Kavenio uses X-Request-Id to protect POST /v1/posts from replay for five minutes. A new ID inside each retry attempt represents a new logical post and can create a duplicate. The exact replay behavior is documented in the Kavenio idempotency reference.

Kavenio then performs the Instagram-specific work: create the Meta media container, wait for it to finish processing, and call media_publish. That is the provider sequence behind the single Kavenio post request; Meta documents the native container lifecycle in its Content Publishing guide.

4. Wait for the post and its Instagram target

posts.create() can return while publishing is still in progress. The overall post and its Instagram target both carry status because a Kavenio post may have more than one destination.

For a worker or a command-line test, poll with a deadline:

const terminalStatuses = new Set([
  "published",
  "failed",
  "partial",
  "action_required",
  "cancelled",
  "unpublished",
]);

export async function waitForInstagramResult(
  postId: string,
  options: { intervalMs?: number; timeoutMs?: number } = {},
) {
  const intervalMs = options.intervalMs ?? 2_000;
  const timeoutMs = options.timeoutMs ?? 120_000;
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const post = await kavenio.posts.get(postId);
    const instagram = post.targets.find(
      (target) => target.platform === "instagram",
    );

    if (!instagram) {
      throw new Error(`Post ${postId} has no Instagram target.`);
    }

    if (
      terminalStatuses.has(post.status) &&
      terminalStatuses.has(instagram.status)
    ) {
      return {
        postId: post.id,
        postStatus: post.status,
        targetStatus: instagram.status,
        nativeUrl: instagram.nativeUrl,
        error: instagram.error,
      };
    }

    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error(`Timed out waiting for Instagram post ${postId}.`);
}

Use this loop in a worker, not in a request handler that must keep an HTTP connection open. In production, subscribe to post.published, post.failed, and post.partial webhooks instead. Kavenio webhooks are delivered at least once, so deduplicate them with x-kavenio-event-id or x-kavenio-delivery-id before changing your own post record.

Put the steps into one worker command

The worker needs only IDs and content that your application already persisted:

export async function publishInstagramImage(input: PublishInstagramImageInput) {
  const post = await createInstagramImagePost(input);

  return waitForInstagramResult(post.id);
}

A caller can now make the request retry-safe without coupling the browser to provider processing:

const result = await publishInstagramImage({
  profileId: "customer_user_123",
  accountId: "acc_instagram_123",
  caption: "Launch day from the workshop.",
  imageUrl: "https://cdn.example.com/posts/launch-day.jpg",
  requestId: "publish_cmd_01J2Y9P6H7M4C3A8Q5T1",
});

if (result.targetStatus !== "published") {
  console.error(result.error ?? result.targetStatus);
}

Persist the Kavenio post ID as soon as posts.create() returns. A local timeout does not mean Instagram rejected the post, and it is not a reason to create a replacement. Fetch the existing post or wait for its webhook before deciding what to do next.

Decide from status, not from elapsed time

Target statusMeaning for your applicationNext action
publishingProvider work is still runningWait or reconcile through a webhook
publishedInstagram accepted the postStore nativeUrl and mark the command complete
failedKavenio has a known failureShow the target error and retry only after fixing its cause
action_requiredThe provider outcome is unsafe to retry automaticallyReconcile the Instagram account/post before another publish
partialAt least one destination succeeded and another did notInspect every target; never replay the whole multi-platform post blindly
cancelledThe command will not publishClose the command without retrying it automatically

The important production behavior is not the happy-path API call. It is keeping tenant ownership, account readiness, request identity, and provider outcome separate. Once those records exist, an Instagram API post can survive worker restarts and uncertain network responses without turning one customer action into two public posts.

See the Kavenio Instagram API for supported Instagram formats and the SDK, REST, and CLI entry points.

Build social posting into your product

Connect accounts, validate content, publish posts, and receive status events through one posting-focused API