<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Oleksandr Pohorelov</title>
    <description>The latest articles on DEV Community by Oleksandr Pohorelov (@alex97po).</description>
    <link>https://dev.to/alex97po</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3825051%2F03dfdf1d-2f79-4303-a1a7-fc343ac7aeaa.png</url>
      <title>DEV Community: Oleksandr Pohorelov</title>
      <link>https://dev.to/alex97po</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alex97po"/>
    <language>en</language>
    <item>
      <title>TikTok Content Publishing API: Videos, Photo Carousels &amp; the Async Publishing Dance</title>
      <dc:creator>Oleksandr Pohorelov</dc:creator>
      <pubDate>Sun, 22 Mar 2026 12:20:59 +0000</pubDate>
      <link>https://dev.to/alex97po/tiktok-content-publishing-api-videos-photo-carousels-the-async-publishing-dance-12g3</link>
      <guid>https://dev.to/alex97po/tiktok-content-publishing-api-videos-photo-carousels-the-async-publishing-dance-12g3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;You'd think posting a video to TikTok via API would be straightforward — upload a file, set a caption, done. Instead, you initialize a publish, TikTok pulls the video from &lt;em&gt;your&lt;/em&gt; server, and you poll for status. Oh, and if you want to post photos? No PNGs allowed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;TikTok's Content Publishing API is fully asynchronous. There's no "upload and get a post ID back" flow. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You &lt;strong&gt;initialize&lt;/strong&gt; a publish request — telling TikTok where to find your media&lt;/li&gt;
&lt;li&gt;TikTok &lt;strong&gt;pulls the media from your URL&lt;/strong&gt; (yes, your file must be publicly accessible)&lt;/li&gt;
&lt;li&gt;You &lt;strong&gt;poll for status&lt;/strong&gt; — the video goes through download, upload processing, and finally publishing&lt;/li&gt;
&lt;li&gt;If it fails, you check the failure reason — some are retryable, some aren't&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is different from platforms like Twitter or LinkedIn where you push media directly. TikTok &lt;em&gt;pulls&lt;/em&gt; it. That means your media hosting needs to serve files publicly, and you need to handle a state machine of publish statuses.&lt;/p&gt;

&lt;p&gt;And then there are the surprises: TikTok doesn't accept PNG images (seriously), privacy levels must match what the creator allows, and the endpoints for video vs. photo posts are completely different.&lt;/p&gt;

&lt;p&gt;Let's walk through all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;TikTok Developer App&lt;/strong&gt; registered at &lt;a href="https://developers.tiktok.com" rel="noopener noreferrer"&gt;developers.tiktok.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A valid &lt;strong&gt;access token&lt;/strong&gt; obtained via TikTok's OAuth 2.0 flow (authorization code + PKCE)&lt;/li&gt;
&lt;li&gt;Required scopes: &lt;code&gt;video.publish&lt;/code&gt;, &lt;code&gt;video.upload&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Media files hosted at a &lt;strong&gt;publicly accessible URL&lt;/strong&gt; — TikTok will download them from your server&lt;/li&gt;
&lt;li&gt;Videos must be MP4. Images must be JPEG or WEBP (not PNG — TikTok will reject them)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TikTok OAuth: Getting Your Access Token
&lt;/h2&gt;

&lt;p&gt;Before you can publish, you need an access token. TikTok uses a standard OAuth 2.0 authorization code flow with PKCE.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Build the Authorization URL
&lt;/h3&gt;

&lt;p&gt;Direct the user to TikTok's authorization page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://www.tiktok.com/v2/auth/authorize/
  ?client_key={your_client_key}
  &amp;amp;response_type=code
  &amp;amp;scope=user.info.basic,video.publish,video.upload
  &amp;amp;redirect_uri={your_redirect_uri}
  &amp;amp;state={random_state}
  &amp;amp;code_challenge={code_challenge}
  &amp;amp;code_challenge_method=S256
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the user approves, TikTok redirects to your &lt;code&gt;redirect_uri&lt;/code&gt; with a &lt;code&gt;code&lt;/code&gt; parameter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Exchange the Code for Tokens
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://open.tiktokapis.com/v2/oauth/token/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-www-form-urlencoded"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_key={your_client_key}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={your_client_secret}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code={authorization_code}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=authorization_code"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"redirect_uri={your_redirect_uri}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code_verifier={code_verifier}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"act.xxxxxxxxxxxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user-open-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rft.xxxxxxxxxxxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;31536000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The access token lasts about &lt;strong&gt;1 day&lt;/strong&gt; (86,400 seconds). The refresh token is valid for &lt;strong&gt;365 days&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Refresh When Expired
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://open.tiktokapis.com/v2/oauth/token/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-www-form-urlencoded"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_key={your_client_key}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={your_client_secret}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=refresh_token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"refresh_token={refresh_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Each refresh response includes a new refresh token. Always store the latest one — the old one gets invalidated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step-by-Step: Publishing a Video
&lt;/h2&gt;

&lt;p&gt;Video publishing uses the &lt;code&gt;PULL_FROM_URL&lt;/code&gt; source type — you provide a URL and TikTok downloads the video from your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Query Creator Info (Optional but Recommended)
&lt;/h3&gt;

&lt;p&gt;Before publishing, check what privacy levels and features the creator has access to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://open.tiktokapis.com/v2/post/publish/creator_info/query/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creator_avatar_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creator_username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"johndoe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creator_nickname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"John"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"privacy_level_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PUBLIC_TO_EVERYONE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MUTUAL_FOLLOW_FRIENDS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FOLLOWER_OF_CREATOR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SELF_ONLY"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"comment_disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duet_disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stitch_disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max_video_post_duration_sec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;privacy_level_options&lt;/code&gt; array tells you which values you're allowed to use when publishing. If you pick a value that isn't in this list, TikTok will reject the publish request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Initialize the Video Publish
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://open.tiktokapis.com/v2/post/publish/video/init/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json; charset=UTF-8"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "post_info": {
      "title": "Check out this awesome video!",
      "privacy_level": "PUBLIC_TO_EVERYONE",
      "disable_duet": false,
      "disable_comment": false,
      "disable_stitch": false,
      "video_cover_timestamp_ms": 1
    },
    "source_info": {
      "source": "PULL_FROM_URL",
      "video_url": "https://your-cdn.com/video.mp4"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"publish_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v_pub_xxxxxxxxxxxx"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; The &lt;code&gt;video_url&lt;/code&gt; must be publicly accessible. TikTok's servers will make an HTTP request to download the file. If it's behind auth or returns a redirect chain that TikTok can't follow, the publish will fail silently during processing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3 — Poll for Publish Status
&lt;/h3&gt;

&lt;p&gt;Publishing is async. After initialization, you need to poll until TikTok finishes processing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://open.tiktokapis.com/v2/post/publish/status/fetch/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "publish_id": "v_pub_xxxxxxxxxxxx"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response (processing):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PROCESSING_DOWNLOAD"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response (complete):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUBLISH_COMPLETE"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response (failed):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAILED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fail_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"picture_size_check_failed"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status progresses through these states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PROCESSING_DOWNLOAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TikTok is downloading your media&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PROCESSING_UPLOAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Media is being processed/transcoded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SEND_TO_USER_INBOX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video sent to creator's inbox for review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUBLISH_COMPLETE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Published successfully&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Something went wrong — check &lt;code&gt;fail_reason&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Polling strategy:&lt;/strong&gt; Wait about &lt;strong&gt;45 seconds&lt;/strong&gt; between polls. Video processing can take a while depending on file size and TikTok's current load. Plan for up to 3 polling attempts before treating it as a timeout.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step-by-Step: Publishing Photos
&lt;/h2&gt;

&lt;p&gt;Photo posts use a different endpoint than videos and support carousels of up to 10 images.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Initialize the Photo Publish
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://open.tiktokapis.com/v2/post/publish/content/init/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json; charset=UTF-8"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "post_info": {
      "title": "Photo carousel post",
      "description": "Check out these shots from my trip!",
      "privacy_level": "PUBLIC_TO_EVERYONE",
      "disable_comment": false
    },
    "source_info": {
      "source": "PULL_FROM_URL",
      "photo_cover_index": 0,
      "photo_images": [
        "https://your-cdn.com/photo1.jpg",
        "https://your-cdn.com/photo2.jpg",
        "https://your-cdn.com/photo3.jpg"
      ]
    },
    "post_mode": "DIRECT_POST",
    "media_type": "PHOTO"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2 — Poll for Status
&lt;/h3&gt;

&lt;p&gt;Same polling flow as video — use &lt;code&gt;/post/publish/status/fetch/&lt;/code&gt; with the &lt;code&gt;publish_id&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The PNG trap:&lt;/strong&gt; TikTok accepts JPEG and WEBP for photos but &lt;strong&gt;rejects PNG&lt;/strong&gt;. If your users upload PNGs, you need to convert them to JPEG before publishing. This is easy to miss because most other platforms accept PNG without issues. Convert on the server side before passing the URL to TikTok.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Public URL Requirement
&lt;/h2&gt;

&lt;p&gt;Unlike platforms where you push binary data directly, TikTok uses a &lt;strong&gt;pull model&lt;/strong&gt; — you give TikTok a URL and their servers fetch the file. This means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your media must be at a publicly accessible URL&lt;/strong&gt; — no auth headers, no short-lived pre-signed URLs that expire in seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The URL must resolve quickly&lt;/strong&gt; — TikTok won't wait long for slow servers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No redirects that TikTok can't follow&lt;/strong&gt; — keep the URL direct&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're using S3-compatible storage (like Tigris, AWS S3, etc.), you'll need to either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make the file public before publishing&lt;/li&gt;
&lt;li&gt;Use a CDN domain alias so the URL looks like &lt;code&gt;https://media.yourdomain.com/video.mp4&lt;/code&gt; instead of an S3 pre-signed URL&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Handling Failures &amp;amp; Retries
&lt;/h2&gt;

&lt;p&gt;Not all failures are permanent. TikTok's &lt;code&gt;fail_reason&lt;/code&gt; values fall into two categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retryable failures&lt;/strong&gt; (try again):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rate_limit_exceeded&lt;/code&gt; — you're making too many requests, back off and retry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Non-retryable failures&lt;/strong&gt; (fix the issue first):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;file_format_check_failed&lt;/code&gt; — wrong file type (e.g., PNG instead of JPEG)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;duration_check_failed&lt;/code&gt; — video too long or too short&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frame_rate_check_failed&lt;/code&gt; — video frame rate out of range&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;picture_size_check_failed&lt;/code&gt; — image dimensions out of range&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spam_risk_too_many_pending_share&lt;/code&gt; — too many pending publishes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;publish_cancelled&lt;/code&gt; — user cancelled in the app&lt;/li&gt;
&lt;li&gt;Various banned/restricted user errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you get a retryable failure, wait a few seconds and re-initialize the publish. For non-retryable failures, you'll need to fix the underlying issue (convert the file format, resize, etc.) before trying again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PNG images are rejected&lt;/strong&gt; — TikTok only accepts JPEG and WEBP for photo posts. Convert PNGs to JPEG on your server before publishing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Videos use a different endpoint than photos&lt;/strong&gt; — &lt;code&gt;/post/publish/video/init/&lt;/code&gt; vs &lt;code&gt;/post/publish/content/init/&lt;/code&gt;. Don't mix them up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy level must match creator's options&lt;/strong&gt; — always query &lt;code&gt;/creator_info/query/&lt;/code&gt; first and use a value from the &lt;code&gt;privacy_level_options&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media URLs must be truly public&lt;/strong&gt; — pre-signed URLs that expire quickly will fail. TikTok needs time to download the file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;title&lt;/code&gt; is the video caption, &lt;code&gt;description&lt;/code&gt; is for photos&lt;/strong&gt; — the naming is confusing but &lt;code&gt;post_info.title&lt;/code&gt; is what appears as the post text for videos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo posts support up to 10 images&lt;/strong&gt; — but you can't mix photos and videos in one post&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polling takes patience&lt;/strong&gt; — video processing can take minutes. Don't poll too aggressively or you'll hit rate limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access tokens expire daily&lt;/strong&gt; — unlike Meta's 60-day tokens, TikTok tokens last only ~24 hours. Make sure your refresh logic runs frequently&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR — The Full Flow (Diagram)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Video Publishing
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F96w4cqoeh5n7xdao5u3u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F96w4cqoeh5n7xdao5u3u.png" alt="TikTok Video Publishing Flow" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Photo Publishing
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fab9m8fabrh38cip3d03q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fab9m8fabrh38cip3d03q.png" alt="TikTok Photo Publishing Flow" width="800" height="672"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  About PostPulse
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developers.post-pulse.com" rel="noopener noreferrer"&gt;PostPulse&lt;/a&gt; handles all of this for you — video and photo publishing, PNG-to-JPEG conversion, public URL hosting, status polling with retry logic, and token refresh — across TikTok and 8 other platforms. Focus on creating content, not wrestling with APIs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.post-pulse.com/portal" rel="noopener noreferrer"&gt;Try PostPulse free →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>automation</category>
      <category>backend</category>
      <category>programming</category>
    </item>
    <item>
      <title>Instagram Container-Based Publishing: The 3-Step Dance for Reels, Stories &amp; Carousels</title>
      <dc:creator>Oleksandr Pohorelov</dc:creator>
      <pubDate>Tue, 17 Mar 2026 10:20:44 +0000</pubDate>
      <link>https://dev.to/alex97po/instagram-container-based-publishing-the-3-step-dance-for-reels-stories-carousels-4e26</link>
      <guid>https://dev.to/alex97po/instagram-container-based-publishing-the-3-step-dance-for-reels-stories-carousels-4e26</guid>
      <description>&lt;p&gt;You can't just &lt;code&gt;POST&lt;/code&gt; an image to Instagram and call it a day. You have to create a "container", wait for Instagram to process it, and &lt;strong&gt;then&lt;/strong&gt; publish it. Oh, and carousels? That's containers inside containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;If you've worked with other social media APIs — Facebook, Twitter, LinkedIn — you're used to a fairly direct flow: upload media, create a post, done. Instagram is different.&lt;/p&gt;

&lt;p&gt;Instagram uses a &lt;strong&gt;container-based publishing model&lt;/strong&gt;. Instead of directly uploading and posting, you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a container&lt;/strong&gt; — tell Instagram where your media lives (a public URL) and what type of post it is&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for processing&lt;/strong&gt; — Instagram downloads your media, validates it, transcodes video, generates thumbnails... and this can take &lt;em&gt;minutes&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the container&lt;/strong&gt; — only after processing is complete&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For carousels, you create individual containers for each item first, then a parent container referencing all the children, wait for &lt;em&gt;that&lt;/em&gt; to process, and then publish.&lt;/p&gt;

&lt;p&gt;If you skip the wait step or try to publish a container that's still &lt;code&gt;IN_PROGRESS&lt;/code&gt;, you'll get errors. If you don't handle the &lt;code&gt;ERROR&lt;/code&gt; status correctly, you'll miss retryable failures. And if you don't know that &lt;code&gt;VIDEO&lt;/code&gt; as a media type is deprecated (use &lt;code&gt;REELS&lt;/code&gt; instead), you'll waste hours debugging.&lt;/p&gt;

&lt;p&gt;Let's walk through all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A valid &lt;strong&gt;long-lived Instagram access token&lt;/strong&gt; (see &lt;a href="https://dev.to/blog/meta-oauth-token-lifecycle"&gt;Part 1: Meta OAuth Token Lifecycle&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Your Instagram account's &lt;strong&gt;user ID&lt;/strong&gt; (returned during authorization)&lt;/li&gt;
&lt;li&gt;Required scope: &lt;code&gt;instagram_content_publish&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Media files hosted at a &lt;strong&gt;publicly accessible URL&lt;/strong&gt; — Instagram will download them from your server (it won't accept direct file uploads)&lt;/li&gt;
&lt;li&gt;Images must be JPEG or PNG. Videos must be MP4&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step-by-Step: Single Media Post
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Create the Container
&lt;/h3&gt;

&lt;p&gt;Tell Instagram about your media by creating a container. The endpoint and parameters differ by media type:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image post:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "image_url": "https://your-cdn.com/photo.jpg",
    "caption": "Check out this view! #travel"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reel (video):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "video_url": "https://your-cdn.com/video.mp4",
    "caption": "Behind the scenes 🎬",
    "media_type": "REELS",
    "cover_url": "https://your-cdn.com/thumbnail.jpg"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Story (image):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "image_url": "https://your-cdn.com/story-photo.jpg",
    "media_type": "STORIES"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Story (video):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "video_url": "https://your-cdn.com/story-video.mp4",
    "media_type": "STORIES"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response (same for all):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17889615691921648"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;id&lt;/code&gt; is your &lt;strong&gt;container ID&lt;/strong&gt;. It's not a published post yet — it's a container that Instagram is now processing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Stories don't get a caption.&lt;/strong&gt; If you pass &lt;code&gt;caption&lt;/code&gt; with &lt;code&gt;media_type: "STORIES"&lt;/code&gt;, it's ignored. Stories are media-only.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;&lt;code&gt;VIDEO&lt;/code&gt; is deprecated.&lt;/strong&gt; Always use &lt;code&gt;REELS&lt;/code&gt; for single video posts. If you send &lt;code&gt;media_type: "VIDEO"&lt;/code&gt;, the API may reject it or behave unexpectedly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2 — Poll Until the Container is Ready
&lt;/h3&gt;

&lt;p&gt;Instagram needs time to download your media, validate it, and process it (especially for video). You must poll the container status before attempting to publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.instagram.com/{container_id}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"fields=status_code,status"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response when still processing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IN_PROGRESS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17889615691921648"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response when ready:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FINISHED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17889615691921648"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response on error:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2207026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17889615691921648"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Polling Strategy: Exponential Backoff
&lt;/h4&gt;

&lt;p&gt;Don't just hammer the endpoint every second. Use increasing delays between attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attempt 1: wait  5 seconds, then poll
Attempt 2: wait 10 seconds, then poll
Attempt 3: wait 15 seconds, then poll
...
Attempt N: wait (5 × N) seconds, then poll
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a maximum of ~25 attempts. For images, you'll typically get &lt;code&gt;FINISHED&lt;/code&gt; within the first few polls. For video (especially longer Reels), it can take several minutes.&lt;/p&gt;

&lt;h4&gt;
  
  
  Handling the ERROR Status
&lt;/h4&gt;

&lt;p&gt;This is where it gets nuanced. An &lt;code&gt;ERROR&lt;/code&gt; status doesn't always mean you should give up. The &lt;code&gt;status&lt;/code&gt; field contains a numeric subcode that tells you what went wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retryable errors&lt;/strong&gt; (create a new container and try again):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subcode&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2207051&lt;/td&gt;
&lt;td&gt;Media download timed out — Instagram couldn't fetch your file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207052&lt;/td&gt;
&lt;td&gt;Media expired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207001&lt;/td&gt;
&lt;td&gt;Server error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207003&lt;/td&gt;
&lt;td&gt;Failed to create media&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207016&lt;/td&gt;
&lt;td&gt;Unknown upload error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207027&lt;/td&gt;
&lt;td&gt;Container not found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207028&lt;/td&gt;
&lt;td&gt;URI fetch failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207050&lt;/td&gt;
&lt;td&gt;Media not ready&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Non-retryable errors&lt;/strong&gt; (fix the issue before retrying):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subcode&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2207006&lt;/td&gt;
&lt;td&gt;Suspected spam&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207024&lt;/td&gt;
&lt;td&gt;Publish limit reached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207009&lt;/td&gt;
&lt;td&gt;Unknown/unsupported media type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207010&lt;/td&gt;
&lt;td&gt;Carousel has invalid item count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207026&lt;/td&gt;
&lt;td&gt;Unsupported video format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207034&lt;/td&gt;
&lt;td&gt;Image too large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207035&lt;/td&gt;
&lt;td&gt;Unsupported image format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207042&lt;/td&gt;
&lt;td&gt;Invalid image aspect ratio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2207048&lt;/td&gt;
&lt;td&gt;Caption too long&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you encounter a retryable error, the right approach is to clear the container reference, wait, and create a fresh container. Don't keep polling a failed container — it won't recover.&lt;/p&gt;

&lt;p&gt;For non-retryable errors, you need to fix the underlying issue (resize the image, change the format, shorten the caption, etc.) before retrying.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Error tolerance during polling:&lt;/strong&gt; In practice, you may see a few generic &lt;code&gt;ERROR&lt;/code&gt; statuses before a container eventually succeeds. A reasonable approach is to tolerate up to ~5 generic errors before giving up, while immediately failing on known non-retryable subcodes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3 — Publish the Container
&lt;/h3&gt;

&lt;p&gt;Once the status is &lt;code&gt;FINISHED&lt;/code&gt;, publish it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media_publish"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "creation_id": "17889615691921648"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17895432187654321"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;code&gt;id&lt;/code&gt; is the actual &lt;strong&gt;published post ID&lt;/strong&gt; — the one you'd use for fetching insights, comments, etc.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The publish call can fail due to rate limits.&lt;/strong&gt; Implement retry with exponential backoff on the publish step too. Instagram returns transient errors here that succeed on retry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step-by-Step: Carousel Post
&lt;/h2&gt;

&lt;p&gt;Carousels add another layer. You need to create a container for each item, then a parent container that references all the children.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Create Child Containers (No Caption)
&lt;/h3&gt;

&lt;p&gt;For each image or video in the carousel, create a container with &lt;code&gt;is_carousel_item: true&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image child:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "image_url": "https://your-cdn.com/photo1.jpg",
    "is_carousel_item": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Video child:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "video_url": "https://your-cdn.com/video1.mp4",
    "media_type": "VIDEO",
    "is_carousel_item": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Note:&lt;/strong&gt; Unlike single video posts where &lt;code&gt;VIDEO&lt;/code&gt; is deprecated in favor of &lt;code&gt;REELS&lt;/code&gt;, carousel video items still use &lt;code&gt;media_type: "VIDEO"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;No caption on children.&lt;/strong&gt; The caption goes on the parent carousel container, not on individual items.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Repeat for each item. Collect all the returned container IDs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Wait for ALL Children to Finish
&lt;/h3&gt;

&lt;p&gt;Poll each child container until it reaches &lt;code&gt;FINISHED&lt;/code&gt;. All children must be ready before you can create the parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Poll each child&lt;/span&gt;
curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.instagram.com/{child_container_id}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"fields=status_code,status"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 — Create the Carousel Parent Container
&lt;/h3&gt;

&lt;p&gt;Once all children are &lt;code&gt;FINISHED&lt;/code&gt;, create the parent container referencing all children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "media_type": "CAROUSEL",
    "children": "17889615691921648,17889615691921649,17889615691921650",
    "caption": "Swipe through our latest collection! ✨"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17889615691921700"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Children is a comma-separated string of IDs&lt;/strong&gt;, not a JSON array. This trips people up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4 — Wait for the Carousel Container
&lt;/h3&gt;

&lt;p&gt;The parent container also needs processing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.instagram.com/{carousel_container_id}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"fields=status_code,status"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5 — Publish the Carousel
&lt;/h3&gt;

&lt;p&gt;Same as before — publish the parent container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.instagram.com/v22.0/{user_id}/media_publish"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "creation_id": "17889615691921700"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handling Container Expiry and Recovery
&lt;/h2&gt;

&lt;p&gt;Containers don't live forever. If you create a container and don't publish it within a certain window, it expires. Your status poll will return &lt;code&gt;EXPIRED&lt;/code&gt;, and you'll need to start over.&lt;/p&gt;

&lt;p&gt;The right recovery strategy depends on the container type:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single media:&lt;/strong&gt; Clear the container reference and create a new container from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Carousel children:&lt;/strong&gt; If a child expires, clear its reference and recreate just that child. You don't need to recreate children that already finished — unless the parent carousel itself hasn't been created yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Carousel parent:&lt;/strong&gt; If the parent carousel container expires or errors, clear the carousel container ID but keep the finished children. Create a new parent container referencing the same children. If the children have also expired, you'll need to recreate those too.&lt;/p&gt;

&lt;p&gt;This is where persistent tracking of container states pays off. For each media item, store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The container ID returned by Instagram&lt;/li&gt;
&lt;li&gt;The current status (&lt;code&gt;IN_PROGRESS&lt;/code&gt;, &lt;code&gt;FINISHED&lt;/code&gt;, &lt;code&gt;ERROR&lt;/code&gt;, &lt;code&gt;EXPIRED&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Whether it's a single item, carousel child, or carousel parent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When retrying, check what's already finished before recreating everything from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiple Stories
&lt;/h2&gt;

&lt;p&gt;Unlike feed posts and carousels, Stories are published individually — there's no grouping mechanism. If you have 5 images to publish as Stories, you create and publish each one separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;For each media file:
  1. Create container (media_type: "STORIES")
  2. Wait for FINISHED
  3. Publish
  4. Move to next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They'll appear in your story timeline in the order you publish them.&lt;/p&gt;

&lt;h2&gt;
  
  
  PNG Handling
&lt;/h2&gt;

&lt;p&gt;Instagram accepts PNG images, but in practice, converting PNG to JPEG before uploading leads to more reliable results and faster processing. If your storage pipeline serves PNGs, consider converting to JPEG server-side before passing the URL to Instagram. This also reduces file size, which means faster downloads for Instagram's servers and fewer &lt;code&gt;MEDIA_DOWNLOAD_TIMEOUT&lt;/code&gt; errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Media must be at a public URL&lt;/strong&gt; — Instagram downloads the file from the URL you provide. If it's behind authentication, a CDN with restricted access, or a pre-signed URL that's expired, you'll get a download timeout error.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;VIDEO&lt;/code&gt; is deprecated for single posts&lt;/strong&gt; — Use &lt;code&gt;REELS&lt;/code&gt; as the &lt;code&gt;media_type&lt;/code&gt; for any single video post. &lt;code&gt;VIDEO&lt;/code&gt; still works for carousel children.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't skip polling&lt;/strong&gt; — Publishing a container that's still &lt;code&gt;IN_PROGRESS&lt;/code&gt; will fail. Always poll until &lt;code&gt;FINISHED&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stories don't support captions&lt;/strong&gt; — The &lt;code&gt;caption&lt;/code&gt; field is ignored for &lt;code&gt;STORIES&lt;/code&gt; media type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Carousel children don't get captions&lt;/strong&gt; — Put the caption on the carousel parent container only.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The &lt;code&gt;children&lt;/code&gt; parameter is a comma-separated string&lt;/strong&gt; — Not a JSON array. &lt;code&gt;"id1,id2,id3"&lt;/code&gt; not &lt;code&gt;["id1","id2","id3"]&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Container IDs expire&lt;/strong&gt; — If you create containers ahead of time (e.g., for scheduled posts), you need recovery logic in case they expire before publishing time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Publish calls need retry logic&lt;/strong&gt; — The &lt;code&gt;media_publish&lt;/code&gt; endpoint is rate-limited and can return transient errors. Implement backoff-based retry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Error subcodes matter&lt;/strong&gt; — Don't treat all &lt;code&gt;ERROR&lt;/code&gt; statuses the same. Some are retryable (server errors, download timeouts), others require fixing the media (wrong format, too large, bad aspect ratio).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR — The Full Flow (Diagram)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Single Media (Image/Reel/Story)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F59q6elhrpjhkl9wjcen6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F59q6elhrpjhkl9wjcen6.png" alt="Single Media Flow Diagram" width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Carousel
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feusuvnaael3umdgsecdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feusuvnaael3umdgsecdo.png" alt="Carousel Flow Diagram" width="800" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop Wrestling with Instagram Containers
&lt;/h2&gt;

&lt;p&gt;If your goal is to build user-facing features rather than managing complex polling loops, retry logic for 2207xxx error codes, and multi-step carousel state machines, you might want to look at &lt;a href="https://developers.post-pulse.com" rel="noopener noreferrer"&gt;PostPulse for Developers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We built the &lt;a href="https://developers.post-pulse.com/getting-started" rel="noopener noreferrer"&gt;PostPulse Social Media API&lt;/a&gt; to turn Instagram’s multi-step "Container" nightmare into a single &lt;code&gt;POST&lt;/code&gt; request. Instead of managing separate flows for Reels, Stories, and Carousels, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Atomic Publishing&lt;/strong&gt;: No more polling for &lt;code&gt;FINISHED&lt;/code&gt; status. Send us the media, and we handle the container creation, processing wait times, and final publication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified Media Handling&lt;/strong&gt;: One standard syntax for &lt;a href="https://developers.post-pulse.com/api-docs#post-scheduling" rel="noopener noreferrer"&gt;scheduling posts&lt;/a&gt; across 9+ platforms. We handle the "video vs. reels" deprecation logic so you don't have to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic Retries&lt;/strong&gt;: If Instagram throws a retryable subcode (like a media download timeout), our system handles the backoff and recovery automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bypass App Review&lt;/strong&gt;: Use our pre-approved Meta, LinkedIn, and TikTok integrations to go live in hours, not weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't waste engineering sprints on social media infrastructure. T&lt;a href="https://developers.post-pulse.com/portal" rel="noopener noreferrer"&gt;ry the PostPulse API for free →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>socialmedia</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Meta OAuth: Short-Lived vs Long-Lived Tokens (and Why Your Token Expires After 1 Hour)</title>
      <dc:creator>Oleksandr Pohorelov</dc:creator>
      <pubDate>Sun, 15 Mar 2026 08:54:30 +0000</pubDate>
      <link>https://dev.to/alex97po/meta-oauth-short-lived-vs-long-lived-tokens-and-why-your-token-expires-after-1-hour-4609</link>
      <guid>https://dev.to/alex97po/meta-oauth-short-lived-vs-long-lived-tokens-and-why-your-token-expires-after-1-hour-4609</guid>
      <description>&lt;p&gt;You got a token from Facebook Login, everything works, and then exactly 1 hour later, your app goes dark. Sound familiar?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Here's what trips up almost every developer integrating with Facebook, Instagram, or Threads for the first time: you complete the OAuth flow, get a shiny access token, make a few API calls, and everything is fine — until it isn't. An hour later, your calls start returning &lt;code&gt;OAuthException&lt;/code&gt; errors, and you're staring at your code, wondering what went wrong.&lt;/p&gt;

&lt;p&gt;The answer? &lt;strong&gt;Meta issues a short-lived token by default.&lt;/strong&gt; It's valid for about 1–2 hours, and most tutorials end there. What they don't tell you is that there's a second exchange step to get a &lt;strong&gt;long-lived token&lt;/strong&gt; (valid for 60 days), and a third step to &lt;strong&gt;refresh&lt;/strong&gt; that long-lived token before it expires.&lt;/p&gt;

&lt;p&gt;Let's walk through the full lifecycle across Facebook, Instagram, and Threads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you start, make sure you have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Facebook App created at Meta for Developers&lt;/li&gt;
&lt;li&gt;App ID (client_id) and App Secret (client_secret)&lt;/li&gt;
&lt;li&gt;For Instagram/Threads: the appropriate product has been added to your Meta App.&lt;/li&gt;
&lt;li&gt;OAuth redirect URI configured in your app settings.&lt;/li&gt;
&lt;li&gt;Required permissions/scopes:&lt;/li&gt;
&lt;li&gt;Facebook: &lt;code&gt;pages_manage_posts&lt;/code&gt;, &lt;code&gt;pages_read_engagement&lt;/code&gt;, &lt;code&gt;public_profile&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Instagram: &lt;code&gt;instagram_basic&lt;/code&gt;, &lt;code&gt;instagram_content_publish&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Threads: &lt;code&gt;threads_basic&lt;/code&gt;, &lt;code&gt;threads_content_publish&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step-by-Step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Get an Authorization Code
&lt;/h3&gt;

&lt;p&gt;Direct the user to the platform-specific authorization URL:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Facebook&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;https://www.facebook.com/v23.0/dialog/oauth
?client_id&lt;span class="o"&gt;={&lt;/span&gt;app_id&lt;span class="o"&gt;}&lt;/span&gt;  
&amp;amp;redirect_uri&lt;span class="o"&gt;={&lt;/span&gt;your_redirect_uri&lt;span class="o"&gt;}&lt;/span&gt;  
&amp;amp;scope&lt;span class="o"&gt;=&lt;/span&gt;pages_manage_posts,pages_read_engagement,public_profile  
&amp;amp;response_type&lt;span class="o"&gt;=&lt;/span&gt;code  
&amp;amp;state&lt;span class="o"&gt;={&lt;/span&gt;csrf_token&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Instagram&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;https://api.instagram.com/oauth/authorize  
?client_id&lt;span class="o"&gt;={&lt;/span&gt;app_id&lt;span class="o"&gt;}&lt;/span&gt;  
&amp;amp;redirect_uri&lt;span class="o"&gt;={&lt;/span&gt;your_redirect_uri&lt;span class="o"&gt;}&lt;/span&gt;  
&amp;amp;scope&lt;span class="o"&gt;=&lt;/span&gt;instagram_basic,instagram_content_publish  
&amp;amp;response_type&lt;span class="o"&gt;=&lt;/span&gt;code  &amp;amp;state&lt;span class="o"&gt;={&lt;/span&gt;csrf_token&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Threads&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;https://www.threads.com/oauth/authorize  
?client_id&lt;span class="o"&gt;={&lt;/span&gt;app_id&lt;span class="o"&gt;}&lt;/span&gt;  
&amp;amp;redirect_uri&lt;span class="o"&gt;={&lt;/span&gt;your_redirect_uri&lt;span class="o"&gt;}&lt;/span&gt;  
&amp;amp;scope&lt;span class="o"&gt;=&lt;/span&gt;threads_basic,threads_content_publish  
&amp;amp;response_type&lt;span class="o"&gt;=&lt;/span&gt;code  
&amp;amp;state&lt;span class="o"&gt;={&lt;/span&gt;csrf_token&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the user grants permissions, Meta redirects back with a &lt;code&gt;code&lt;/code&gt; parameter. This code is single-use and expires in about 10 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Exchange the Code for a Short-Lived Token
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Facebook&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.facebook.com/v23.0/oauth/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code={authorization_code}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=authorization_code"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id={app_id}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"redirect_uri={your_redirect_uri}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Instagram&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.instagram.com/oauth/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code={authorization_code}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=authorization_code"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id={app_id}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"redirect_uri={your_redirect_uri}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Threads&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.threads.net/oauth/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"code={authorization_code}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=authorization_code"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id={app_id}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"redirect_uri={your_redirect_uri}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EAAG...short-lived-token..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ This is the short-lived token. It expires in ~1 hour. If you stop here, your integration will break every 60 minutes. Don’t stop here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Exchange Short-Lived Token for Long-Lived Token
&lt;/h3&gt;

&lt;p&gt;This is the step most tutorials skip. You take the short-lived token from Step 2 and exchange it for a long-lived one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Facebook&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.facebook.com/v23.0/oauth/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=fb_exchange_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id={app_id}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"fb_exchange_token={short_lived_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Instagram&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.instagram.com/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=ig_exchange_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"access_token={short_lived_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Threads&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.threads.net/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=th_exchange_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"access_token={short_lived_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EAAG...long-lived-token..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5184000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 5184000 is 60 days in seconds. You just went from 1 hour to 60 days.&lt;/p&gt;

&lt;p&gt;⚠️ Notice the different grant types: Facebook uses &lt;code&gt;fb_exchange_token&lt;/code&gt;, while Instagram uses &lt;code&gt;ig_exchange_token&lt;/code&gt;, and Threads uses &lt;code&gt;th_exchange_token&lt;/code&gt;. They’re the same concept but different parameter values. Mix them up, and you’ll get an unhelpful error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Refresh the Long-Lived Token (Before It Expires)
&lt;/h3&gt;

&lt;p&gt;Long-lived tokens can be refreshed for another 60 days. But there are conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The token must be at least 24 hours old&lt;/li&gt;
&lt;li&gt;The token must not have expired yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Facebook&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.facebook.com/v23.0/oauth/access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=fb_exchange_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id={app_id}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={app_secret}"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"fb_exchange_token={long_lived_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Instagram&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.instagram.com/refresh_access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=ig_refresh_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"access_token={long_lived_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Threads&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://graph.threads.net/refresh_access_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=th_refresh_token"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"access_token={long_lived_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;💡 Pro tip: Set up a scheduled job to refresh tokens that are between 24 hours and 59 days old. If you miss the window, the user will need to re-authenticate from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stopping at the short-lived token&lt;/strong&gt; — The #1 mistake. Your app will work for an hour in development and then break in production. Always exchange for long-lived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing up grant types&lt;/strong&gt; — Facebook uses &lt;code&gt;fb_exchange_token&lt;/code&gt;, Instagram uses &lt;code&gt;ig_exchange_token&lt;/code&gt; for the initial exchange and &lt;code&gt;ig_refresh_token&lt;/code&gt; for refresh. They look similar, but they’re not interchangeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trying to refresh too early&lt;/strong&gt; — If the long-lived token is less than 24 hours old, the refresh call will silently return the same token with the same expiry. It won’t error — it just won’t do anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trying to refresh an expired token&lt;/strong&gt; — Once expired, you can’t refresh it. The user must go through the full OAuth flow again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not storing the refresh token separately&lt;/strong&gt; — For Meta, the long-lived token IS the refresh token. You exchange the existing long-lived token for a new long-lived token. This is different from platforms like Twitter or TikTok, which give you separate access_token and refresh_token values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting about Page Access Tokens&lt;/strong&gt; — If you’re posting to Facebook Pages, you also need a Page Access Token (covered in the next article). A user token alone won’t let you post to a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not handling Instagram’s different base URLs&lt;/strong&gt; — The initial token exchange goes to api.instagram.com, but the long-lived exchange and refresh go to graph.instagram.com. Different hosts for different operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR — The Full Flow
&lt;/h2&gt;

&lt;p&gt;The complete Meta OAuth token lifecycle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User clicks “Login” → Redirect to platform's authorization URL.&lt;/li&gt;
&lt;li&gt;User grants permissions → Redirect back with &lt;code&gt;?code=...&lt;/code&gt; (expires in ~10 min)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /oauth/access_token&lt;/code&gt; → Short-lived token (~1 hour)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;⭐ THE STEP EVERYONE MISSES: Exchange with fb_exchange_token (FB), ig_exchange_token (IG), or th_exchange_token (Threads) → Long-lived token (60 days)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Store token. Set up a refresh job.&lt;/li&gt;
&lt;li&gt;Refresh every ~30 days (must be ≥24h old, must not be expired). FB: &lt;code&gt;fb_exchange_token&lt;/code&gt;, IG: &lt;code&gt;ig_refresh_token&lt;/code&gt;, Threads: &lt;code&gt;th_refresh_token&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick Reference Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;FACEBOOK&lt;/th&gt;
&lt;th&gt;INSTAGRAM&lt;/th&gt;
&lt;th&gt;THREADS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Authorize URL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.facebook.com/v23.0/dialog/oauth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.instagram.com/oauth/authorize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.threads.com/oauth/authorize&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth code exchange&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST graph.facebook.com/v23.0/oauth/access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST api.instagram.com/oauth/access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST graph.threads.net/oauth/access_token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Short → Long exchange URL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST graph.facebook.com/v23.0/oauth/access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET graph.instagram.com/access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET graph.threads.net/access_token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Short → Long exchange grant type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fb_exchange_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ig_exchange_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;th_exchange_token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refresh URL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET graph.facebook.com/v23.0/oauth/access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET graph.instagram.com/refresh_access_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET graph.threads.net/refresh_access_token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refresh grant type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fb_exchange_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ig_refresh_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;th_refresh_token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Token lifetime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refresh window&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h after creation → before expiry&lt;/td&gt;
&lt;td&gt;24h after creation → before expiry&lt;/td&gt;
&lt;td&gt;24h after creation → before expiry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Dealing with the Meta API Headache?
&lt;/h2&gt;

&lt;p&gt;If you've read this far, you know that Meta’s documentation is a moving target. Between versioned Graph API changes (like the jump to v23.0), inconsistent base URLs for Instagram, and the rigid 24-hour-to-60-day refresh window, token management becomes a high-maintenance sub-system in your codebase.&lt;/p&gt;

&lt;p&gt;Beyond the tokens, getting your app through Meta's &lt;strong&gt;App Review&lt;/strong&gt; and &lt;strong&gt;Business Verification&lt;/strong&gt; is a multi-week process that requires screencasts, privacy policies, and strict data handling audits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consider a Unified API
&lt;/h3&gt;

&lt;p&gt;If your goal is to build features rather than manage OAuth lifecycles and audit compliance, you might want to look at &lt;a href="https://developers.post-pulse.com" rel="noopener noreferrer"&gt;PostPulse for Developers&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;We’ve built the &lt;a href="https://developers.post-pulse.com/getting-started" rel="noopener noreferrer"&gt;PostPulse Social Media API&lt;/a&gt; specifically to solve these "last mile" integration problems. Instead of managing three different refresh flows for Facebook, Instagram, and Threads, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed Token Refresh:&lt;/strong&gt; We handle the 24h/60-day logic for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified Endpoints:&lt;/strong&gt; One standard syntax for &lt;a href="https://developers.post-pulse.com/api-docs#post-scheduling" rel="noopener noreferrer"&gt;scheduling posts&lt;/a&gt; across 9+ platforms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bypass App Review:&lt;/strong&gt; Use our pre-approved Meta, LinkedIn, and TikTok integrations to go live in hours, not weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save your engineering hours for your core product. &lt;a href="https://developers.post-pulse.com/portal" rel="noopener noreferrer"&gt;Try the PostPulse API for free →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
