n8n can publish to Instagram on a schedule with nothing but its own core nodes and Meta's official API. This guide builds that workflow: a trigger, three HTTP Request nodes, a wait loop that checks the post is ready, and a stop when it is not. The template at the end imports in one paste.
It follows the same API calls as our Instagram API guide, where each call was run on a real account. Read that one for the details of each call; this one is about doing it well in n8n.
The short version
- An Instagram professional account and a Meta app with Instagram Login. No Facebook Page needed.
- A 60-day token from the App Dashboard, stored in n8n as a Header Auth credential.
- In n8n: Schedule Trigger, a node with the image URL and caption, then Create container, Wait, Check status, and Publish once the status is
FINISHED. - Something that renews the token before day 60, and something that marks each post as done.
Before you start
- The account. Meta's API only serves professional accounts, business or creator.
- The app. Create a Meta app with the Instagram product and Instagram Login. If you only post to accounts you own or manage, Standard Access is enough and there is no App Review.
- The token. The Generate token button in the App Dashboard gives you a token for your own account that lasts 60 days (get started). It needs
instagram_business_basicandinstagram_business_content_publish. - The account id. Call
GET https://graph.instagram.com/v26.0/me?fields=user_id,usernamewith the token and keepuser_id. The publishing calls takeuser_id, notid. - Public media. Meta downloads each file from its URL, so the image or video must be on a publicly accessible server when the workflow runs. Images must be JPEG. A link that opens a preview page, such as a cloud drive share link, is not the file.
Which n8n node to use
n8n's built-in Facebook Graph API node sends requests to graph.facebook.com or graph-video.facebook.com only (n8n docs). Many n8n Instagram tutorials use it, which ties them to Facebook Login and a Facebook Page linked to the account.
With Instagram Login the host is graph.instagram.com, so this guide uses the HTTP Request node. It is also easier to read: every call is a URL and a JSON body you can paste into curl to debug.
| Instagram Login + HTTP Request | Facebook Login + Facebook Graph API node | |
|---|---|---|
| Needs a Facebook Page | No | Yes, linked to the Instagram account |
| Host | graph.instagram.com |
graph.facebook.com |
| Upload a video file instead of a URL | No | Yes, resumable upload |
| Delete a post later | No | Yes |
Sources: Meta's overview and content publishing. If you need deletes or file uploads, use Facebook Login; the flow below stays the same with the other host.
Store the token as a credential
Do not type the token into the URL of each node: exported workflows and shared screenshots carry it with them. Create one credential and select it on every HTTP Request node.
- In an HTTP Request node, set Authentication to Generic Credential Type and Generic Auth Type to Header Auth.
- Create a new credential: Name
Authorization, ValueBearer <your token>(n8n docs).
Meta's publishing examples send the token in that same header (content publishing).
The workflow, node by node
| Node | Type | What it does |
|---|---|---|
| Every day at 9:00 | Schedule Trigger | Starts the run |
| Post | Edit Fields (Set) | Holds image_url and caption. Replace it with your source |
| Create container | HTTP Request | POST /<IG_ID>/media with the URL and caption |
| Wait 30 s | Wait | Gives Meta time to fetch and process the file |
| Check status | HTTP Request | GET /<container id>?fields=status_code |
| Ready? | If | status_code is FINISHED |
| Keep waiting? | If | Still IN_PROGRESS and fewer than 10 checks: back to Wait |
| Not published | Stop and Error | Fails the run with the status, so you hear about it |
| Publish | HTTP Request | POST /<IG_ID>/media_publish with the container id |
Create container
Method POST, URL https://graph.instagram.com/v26.0/<IG_ID>/media, Send Body on, Body Content Type JSON, Specify Body Using JSON:
{{ JSON.stringify({ image_url: $json.image_url, caption: $json.caption }) }}JSON.stringify matters. Captions hold quotes, line breaks and emoji, and a body built by pasting "{{ $json.caption }}" into a JSON template breaks on the first quote. Meta answers with the container id: {"id":"17889455560051444"}.
Wait, then check
Meta processes every container in the background, images included, and getting an id back does not mean the file worked. Publish only when the container's status_code is FINISHED (IG User Media). In our Instagram API test, an image published right after its container was created failed with 9007 / 2207027 (not ready), and worked seconds later.
A fixed Wait of one minute, which many templates use, works for small images and fails on the day a video takes longer. A loop does not:
- Check status:
GET https://graph.instagram.com/v26.0/{{ $('Create container').first().json.id }}?fields=status_code - Ready?:
{{ $json.status_code }}is equal toFINISHED. True goes to Publish. - Keep waiting?:
{{ $json.status_code }}is equal toIN_PROGRESSand{{ $runIndex }}is less than9. True goes back to Wait; false goes to Stop and Error.
$runIndex counts how many times the node has run in this execution, from 0 (n8n docs), so the loop gives up after 10 checks, five minutes in all. That matches Meta's advice to check once per minute, for no more than 5 minutes, checked twice as often. A Wait under 65 seconds keeps the execution in memory instead of saving it to the database (n8n docs).
The other values are final: ERROR (the container's status field holds the subcode), EXPIRED (not published within 24 hours) and PUBLISHED (IG Container).
Publish
Method POST, URL https://graph.instagram.com/v26.0/<IG_ID>/media_publish, JSON body:
{{ JSON.stringify({ creation_id: $('Create container').first().json.id }) }}The id it returns is the published post. Save it next to the row it came from, so the next run skips that post.
Do not turn on Retry On Fail for Publish
n8n can retry a failed node, and for Create container and Check status that is fine. For Publish it is how you post twice: a call that timed out may have published. Before trying again, read the container's status, and if it is PUBLISHED, the post is live (IG Container).
A success can also be partial. media_publish can return the post id with config_issue set to CAPTION_NOT_ATTACHED or USER_TAGGING_FAILURE: the post is live without its caption or tags, and Meta says not to publish it again (media publish). Add an If on {{ $json.config_issue }} after Publish if you want to hear about it.
Reels instead of images
Change the Create container body. Reels take longer to process, so set the Wait to 60 seconds and the $runIndex limit in Keep waiting? to 4: five checks, a minute apart.
{{ JSON.stringify({ media_type: "REELS", video_url: $json.video_url, caption: $json.caption, share_to_feed: true }) }}Reels are MP4 or MOV, up to 300 MB and 15 minutes (IG User Media). Carousels and stories use the same loop with a different body: see carousels and stories in the API guide.
Where the posts come from
The template keeps the post in one Edit Fields node so it runs as soon as you import it. In practice, replace that node with your queue:
- Google Sheets or Airtable: a row per post with the media URL, caption, date and a status column. Read the first row that is due and not posted; after Publish, write the post id and the status back. Without that write, the next run posts the same row again.
- An RSS feed: RSS Feed Trigger, then build the caption from the item's title and description.
- An AI node for the caption: keep a person in the loop for a while, for example a Slack or email approval before Create container. Captions take up to 2,200 characters, 30 hashtags and 20 @ tags (IG User Media).
Keep the token alive
The token from the dashboard lasts 60 days. It can be refreshed once it is at least 24 hours old, and each refresh gives another 60 days; a token left to expire cannot be refreshed (Business Login for Instagram).
GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=<LONG_LIVED_TOKEN>A second workflow on a Schedule Trigger every few weeks can call it; then put the new token in the Header Auth credential. Until that is automated, keep a calendar reminder: after day 60 every run fails with code 190.
Limits to plan for
- Posts per day: each account has a publishing limit in a rolling 24 hours, and Meta's pages give 50 in some places and 100 in others. Read it from
GET /<IG_ID>/content_publishing_limit?fields=quota_usage,config(reference). Our test account returned 100 in October 2026. - Containers: at most 400 created per account in 24 hours, and each expires after 24 hours (IG User Media). A loop that creates containers and fails before publishing uses them up.
- Errors: the ones you will meet most are in the error table of the API guide. Code
190means the token expired or was revoked.
The template
Copy this, open a new workflow in n8n and paste it on the canvas. Then replace <IG_ID> in the three HTTP Request nodes, select your Header Auth credential on each, and put a public JPEG URL in the Post node.
We imported this template into n8n 2.15 and ran it against a real business account in October 2026, with Instagram Login. A 1600x1000 JPEG was FINISHED at the first check, even with the Wait set to 1 second. A 20-second reel, with the body from "Reels instead of images" and the Wait cut to 5 seconds for the test, was IN_PROGRESS at four checks, went back through Keep waiting? each time, and was FINISHED and published at the fifth: the loop is there for video.
{
"name": "Instagram auto-post",
"nodes": [
{
"id": "986cc635-0ed6-432e-be1d-2126c2b8f8b0",
"name": "Every day at 9:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0],
"parameters": { "rule": { "interval": [{ "triggerAtHour": 9 }] } }
},
{
"id": "2a861e94-f258-46a7-9fbf-ae9de2c3595e",
"name": "Post",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [220, 0],
"parameters": {
"assignments": {
"assignments": [
{ "id": "13a43bf8-aa14-43e9-82b5-96e855381478", "name": "image_url", "value": "https://example.com/photos/autumn-menu.jpg", "type": "string" },
{ "id": "6554cc9b-50d7-4dd2-af99-e965cbd96da4", "name": "caption", "value": "Autumn menu is here. #coffee", "type": "string" }
]
},
"options": {}
}
},
{
"id": "788bbddf-072c-4b7d-90e8-c973ca48b9a2",
"name": "Create container",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [440, 0],
"parameters": {
"method": "POST",
"url": "https://graph.instagram.com/v26.0/<IG_ID>/media",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ image_url: $json.image_url, caption: $json.caption }) }}",
"options": {}
}
},
{
"id": "01daf579-5921-4648-9bd2-e10e20dda449",
"name": "Wait 30 s",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [660, 0],
"parameters": { "resume": "timeInterval", "amount": 30, "unit": "seconds" }
},
{
"id": "68696cd3-87b2-4570-97fa-b6f3dabb8042",
"name": "Check status",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [880, 0],
"parameters": {
"url": "=https://graph.instagram.com/v26.0/{{ $('Create container').first().json.id }}?fields=status_code",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
}
},
{
"id": "8f7d120c-4ca8-4075-a064-b30837b61f1d",
"name": "Ready?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [1100, 0],
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
"conditions": [
{ "id": "e884cf4f-2085-4f6d-8962-ce22bbf635b0", "leftValue": "={{ $json.status_code }}", "rightValue": "FINISHED", "operator": { "type": "string", "operation": "equals" } }
],
"combinator": "and"
},
"options": {}
}
},
{
"id": "cb88af8b-406f-49f2-82be-b2672079f1eb",
"name": "Keep waiting?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [1320, 200],
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
"conditions": [
{ "id": "1010e9ea-fc74-44a9-accb-c4808f8f1c84", "leftValue": "={{ $json.status_code }}", "rightValue": "IN_PROGRESS", "operator": { "type": "string", "operation": "equals" } },
{ "id": "365b894b-1d83-466a-b5bd-7298ab22fc92", "leftValue": "={{ $runIndex }}", "rightValue": 9, "operator": { "type": "number", "operation": "lt" } }
],
"combinator": "and"
},
"options": {}
}
},
{
"id": "a3f0c2d1-5b7e-4c9a-8d6f-2e1b0c9d8a7f",
"name": "Not published",
"type": "n8n-nodes-base.stopAndError",
"typeVersion": 1,
"position": [1540, 300],
"parameters": { "errorMessage": "={{ 'Container ' + $('Create container').first().json.id + ' is ' + $json.status_code }}" }
},
{
"id": "b4e1d3c2-6a8f-4d0b-9e7a-3f2c1d0e9b8a",
"name": "Publish",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1320, -100],
"parameters": {
"method": "POST",
"url": "https://graph.instagram.com/v26.0/<IG_ID>/media_publish",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ creation_id: $('Create container').first().json.id }) }}",
"options": {}
}
}
],
"connections": {
"Every day at 9:00": { "main": [[{ "node": "Post", "type": "main", "index": 0 }]] },
"Post": { "main": [[{ "node": "Create container", "type": "main", "index": 0 }]] },
"Create container": { "main": [[{ "node": "Wait 30 s", "type": "main", "index": 0 }]] },
"Wait 30 s": { "main": [[{ "node": "Check status", "type": "main", "index": 0 }]] },
"Check status": { "main": [[{ "node": "Ready?", "type": "main", "index": 0 }]] },
"Ready?": {
"main": [
[{ "node": "Publish", "type": "main", "index": 0 }],
[{ "node": "Keep waiting?", "type": "main", "index": 0 }]
]
},
"Keep waiting?": {
"main": [
[{ "node": "Wait 30 s", "type": "main", "index": 0 }],
[{ "node": "Not published", "type": "main", "index": 0 }]
]
}
},
"settings": {}
}The template posts one item per run, which is why the expressions read $('Create container').first(). For several posts in one run, put a Loop Over Items node with a batch size of 1 around the nodes from Create container to Publish (n8n docs).
When n8n alone is the better choice
For one or two accounts you own, this workflow is all you need: Standard Access takes no review, and n8n's core nodes cost nothing extra. What grows with more accounts is the work around it: a token per account to refresh, a Meta app review to serve accounts you do not own, a public host for every file, and a count against each account's daily limit.
Where Rizamo fits
Rizamo is a social media API and MCP server built to take that layer off your hands: one call to publish, with Rizamo creating the container, waiting for it, publishing it and refreshing the tokens. In n8n it is called with the HTTP Request node, or its MCP tools go on the AI Agent node (n8n on Rizamo). Rizamo is opening soon: join the waitlist.