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:
- starts an Instagram connection for one customer profile;
- verifies the connected account before publishing;
- creates an image post with a retry-safe request ID; and
- 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:
| Boundary | Responsibility | Data it may keep |
|---|---|---|
| Browser | Send the user to the authorization URL and show the connection result | Kavenio account ID, never the Kavenio API key |
| Application server | Start connections, persist account ownership, enqueue publish work | Tenant/profile ID, account ID, post command ID |
| Worker | Check readiness, create the post, and reconcile its final state | Kavenio 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 status | Meaning for your application | Next action |
|---|---|---|
publishing | Provider work is still running | Wait or reconcile through a webhook |
published | Instagram accepted the post | Store nativeUrl and mark the command complete |
failed | Kavenio has a known failure | Show the target error and retry only after fixing its cause |
action_required | The provider outcome is unsafe to retry automatically | Reconcile the Instagram account/post before another publish |
partial | At least one destination succeeded and another did not | Inspect every target; never replay the whole multi-platform post blindly |
cancelled | The command will not publish | Close 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.