A Ruby client for the Instagram Graph API, supporting Business and Creator
accounts. Successor to instagram_basic_display_api (Meta retired Basic
Display in late 2024).
Add to your Gemfile:
gem 'instagram_graph_api', git: 'https://github.com/sixoverground/instagram_graph_api.git', tag: 'v1.1.0'Then run bundle install.
client = InstagramGraphAPI.client(access_token: 'IGAA...')me = client.user(fields: 'id,username,name,account_type,media_count,followers_count')
me.username # => "snoopdog"
me.account_type # => "BUSINESS"page = client.user_recent_media(limit: 25)
page.data.each { |media| puts media.permalink }
next_after = page.paging&.cursors&.after
item = client.media_item('17841405822304914')
item.media_type # => "IMAGE" / "VIDEO" / "CAROUSEL_ALBUM" / "REELS"High-level helpers run the full container-create → poll-status →
publish lifecycle and return a PublishResult struct:
result = client.publish.single_image(
ig_user_id: '17841400000000000',
image_url: 'https://cdn.example.com/photo.jpg',
caption: 'hello world'
)
result.status # => :published
result.media_id # => "17900..."
result.container_id # => "18000..."
client.publish.carousel(
ig_user_id: '17841400000000000',
child_image_urls: ['https://.../1.jpg', 'https://.../2.jpg', 'https://.../3.jpg'],
caption: 'carousel'
)
client.publish.reel(
ig_user_id: '17841400000000000',
video_url: 'https://cdn.example.com/reel.mp4',
caption: 'new reel',
share_to_feed: true
)
client.publish.story(
ig_user_id: '17841400000000000',
image_url: 'https://cdn.example.com/story.jpg'
)Each helper accepts these polling controls:
| Option | Default | Notes |
|---|---|---|
poll: |
true |
When false, publishes immediately without checking container status. |
poll_interval: |
2 |
Seconds between status polls. |
poll_timeout: |
300 |
Seconds before giving up; result returns status: :timeout. |
If the container reports ERROR, the helper returns status: :error.
Drop down to the low-level endpoints whenever the helper is too opinionated:
container = client.create_media_container(ig_user_id: ..., image_url: ..., caption: ...)
client.media_container_status(container_id: container.id).status_code
client.publish_media_container(ig_user_id: ..., creation_id: container.id)client.user_stories # /me/stories
client.user_tagged_media(limit: 25) # /me/tags
client.hashtag_search(query: 'california') # /ig_hashtag_search
client.hashtag_top_media(hashtag_id: '17841...') # /{id}/top_media
client.hashtag_recent_media(hashtag_id: '17841...')# /{id}/recent_media# per-post — Symbol shorthand expands to the per-kind whitelist
client.media_insights(media_id: '17841...', metric: :reel)
client.media_insights(media_id: '17841...', metric: %w[reach likes saved])
# account-level — :account expands to the full account metric whitelist
client.user_insights(metric: :account, period: 'day')
client.user_insights(ig_user_id: '17841...', metric: 'views', since: '...', until_at: '...')The metric whitelist lives in InstagramGraphAPI::Metrics:
InstagramGraphAPI::Metrics::MEDIA_INSIGHT_METRICS[:reel]
# => ["reach", "likes", "comments", "shares", "saved", "total_interactions",
# "plays", "views", "ig_reels_avg_watch_time", "ig_reels_video_view_total_time"]
InstagramGraphAPI::Metrics::ACCOUNT_INSIGHT_METRICS
# => ["views", "profile_views", "follower_count", "accounts_engaged",
# "total_interactions", "reach", "likes", "comments", "shares", "saves"]Deprecated names (impressions, engagement, video_views) are
intentionally absent — the Graph v21 schema renamed/retired them in
2024–2025.
client.media_comments(media_id: '17841...') # GET comments
client.reply_to_media(media_id: '17841...', message: 'thanks all!') # top-level reply
client.reply_to_comment(comment_id: '17865...', message: 'thanks!') # threaded reply
client.comment_replies(comment_id: '17865...') # read repliesfresh = client.refresh_access_token
fresh.access_token # => "IGAA..."
fresh.expires_in # => 5183944Validate locally before burning a container quota:
InstagramGraphAPI::Validators::Image.validate(
'https://cdn.example.com/photo.jpg',
size_bytes: 4 * 1024 * 1024,
aspect_ratio: 1.0
)
InstagramGraphAPI::Validators::Video.validate(
'https://cdn.example.com/reel.mp4',
kind: :reel,
size_bytes: 40 * 1024 * 1024,
duration_seconds: 45,
video_codec: 'h264',
audio_codec: 'aac'
)
InstagramGraphAPI::Validators::Carousel.validate([
'https://cdn.example.com/1.jpg',
'https://cdn.example.com/2.jpg'
])The validators only check metadata the caller supplies — the gem does
not fetch remote URLs. On failure they raise
InstagramGraphAPI::ValidationError whose #errors is the full list of
issues found.
Non-2xx responses raise per-status exception classes:
| Status | Class |
|---|---|
| 400 | BadRequest |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | TooManyRequests (#retry_after parses the Retry-After header) |
| 500 | InternalServerError |
| 502 | BadGateway |
| 503 | ServiceUnavailable |
| 504 | GatewayTimeout |
Each carries #http_status, #payload (the parsed JSON body) and
#headers (downcased response headers).
begin
client.user_recent_media
rescue InstagramGraphAPI::TooManyRequests => e
sleep(e.retry_after || 60)
retry
end| Method | Notes |
|---|---|
Client#user / #me |
Read profile. |
Client#user_recent_media / #recent_media / #media_item / #media |
Read media. |
Client#refresh_access_token |
Long-lived token refresh. |
Client#create_media_container / #publish_media_container / #media_container_status |
Publish lifecycle (low-level). |
Client#publish.single_image / .carousel / .reel / .story |
Publish lifecycle (high-level). |
Client#user_stories / #stories |
Stories. |
Client#user_tagged_media / #tagged_media |
Tagged media. |
Client#media_insights / #user_insights |
Insights. |
Client#media_comments / #reply_to_media / #reply_to_comment / #comment_replies |
Comments + replies. |
Client#hashtag_search / #hashtag_top_media / #hashtag_recent_media |
Hashtags. |
bundle install
bundle exec rspecSpecs use WebMock with JSON fixtures captured against a sandbox Business account; no network calls are made in CI.
MIT. See LICENSE.txt.