Skip to main content
By Marcel Czuryszkiewicz, Founder @ bundle.social

TL;DR

  • No SDK needed. Native Ruby net/http or Faraday works.
  • One API key, one base URL, 14+ platforms. Upload media, create posts, pull analytics.
  • Every code example verified against the actual API contracts. Copy-paste with confidence.

What You’ll Need

  • Ruby 3.0+ (2.7+ compatible)
  • Rails 7+ (optional, for Rails-specific examples)
  • bundle.social API key - get one from the dashboard
No gems required for basic usage. We’ll show both pure Ruby (net/http) and Faraday approaches. The endpoints and payloads are identical either way.

Setup

Option 1: Pure Ruby (no gems)

require 'net/http'
require 'json'
require 'uri'

class BundleSocialClient
  BASE_URL = 'https://api.bundle.social/api/v1'.freeze

  def initialize(api_key)
    @api_key = api_key
  end

  def get(endpoint, params = {})
    uri = URI("#{BASE_URL}#{endpoint}")
    uri.query = URI.encode_www_form(params) unless params.empty?

    request = Net::HTTP::Get.new(uri)
    request['x-api-key'] = @api_key
    request['Content-Type'] = 'application/json'

    execute_request(uri, request)
  end

  def post(endpoint, body)
    uri = URI("#{BASE_URL}#{endpoint}")

    request = Net::HTTP::Post.new(uri)
    request['x-api-key'] = @api_key
    request['Content-Type'] = 'application/json'
    request.body = body.to_json

    execute_request(uri, request)
  end

  def upload_file(file_path, team_id)
    uri = URI("#{BASE_URL}/upload")

    boundary = "----RubyBoundary#{rand(1_000_000)}"

    file_content = File.binread(file_path)
    file_name = File.basename(file_path)

    body = []
    body << "--#{boundary}\r\n"
    body << "Content-Disposition: form-data; name=\"teamId\"\r\n\r\n"
    body << "#{team_id}\r\n"
    body << "--#{boundary}\r\n"
    body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{file_name}\"\r\n"
    body << "Content-Type: application/octet-stream\r\n\r\n"
    body << file_content
    body << "\r\n--#{boundary}--\r\n"

    request = Net::HTTP::Post.new(uri)
    request['x-api-key'] = @api_key
    request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
    request.body = body.join

    execute_request(uri, request)
  end

  def delete(endpoint)
    uri = URI("#{BASE_URL}#{endpoint}")

    request = Net::HTTP::Delete.new(uri)
    request['x-api-key'] = @api_key
    request['Content-Type'] = 'application/json'

    execute_request(uri, request)
  end

  private

  def execute_request(uri, request)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true

    response = http.request(request)

    case response
    when Net::HTTPSuccess
      JSON.parse(response.body)
    else
      error = JSON.parse(response.body) rescue { 'message' => response.body }
      raise StandardError, "API Error (#{response.code}): #{error['message']}"
    end
  end
end
# Gemfile
gem 'faraday'
gem 'faraday-multipart'
require 'faraday'
require 'faraday/multipart'

class BundleSocialClient
  BASE_URL = 'https://api.bundle.social/api/v1'.freeze

  def initialize(api_key)
    @api_key = api_key
    @conn = build_connection
    @multipart_conn = build_multipart_connection
  end

  def get(endpoint, params = {})
    response = @conn.get(endpoint, params)
    handle_response(response)
  end

  def post(endpoint, body)
    response = @conn.post(endpoint, body.to_json)
    handle_response(response)
  end

  def delete(endpoint)
    response = @conn.delete(endpoint)
    handle_response(response)
  end

  def upload_file(file_path, team_id)
    payload = {
      file: Faraday::Multipart::FilePart.new(file_path, 'application/octet-stream'),
      teamId: team_id
    }

    response = @multipart_conn.post('/upload', payload)
    handle_response(response)
  end

  private

  def build_connection
    Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.response :json
      f.headers['x-api-key'] = @api_key
      f.headers['Content-Type'] = 'application/json'
    end
  end

  def build_multipart_connection
    Faraday.new(url: BASE_URL) do |f|
      f.request :multipart
      f.response :json
      f.headers['x-api-key'] = @api_key
    end
  end

  def handle_response(response)
    if response.success?
      response.body
    else
      raise StandardError, "API Error (#{response.status}): #{response.body['message'] || response.body}"
    end
  end
end

Initialize Client

# Using environment variables (recommended)
client = BundleSocialClient.new(ENV['BUNDLESOCIAL_API_KEY'])
TEAM_ID = ENV['BUNDLESOCIAL_TEAM_ID']

Upload Media

# Upload a video
upload = client.upload_file('./video.mp4', TEAM_ID)

puts "Upload ID: #{upload['id']}"
puts "URL: #{upload['url']}"
puts "Type: #{upload['type']}"       # "image", "video", or "document"
puts "MIME: #{upload['mime']}"       # e.g. "video/mp4"

# Upload multiple files for carousel
upload_ids = ['./image1.jpg', './image2.jpg', './image3.jpg'].map do |path|
  client.upload_file(path, TEAM_ID)['id']
end

puts "Uploaded #{upload_ids.length} files"

Create Posts

Post to Instagram

post = client.post('/post', {
  teamId: TEAM_ID,
  title: 'My Instagram Reel',
  postDate: Time.now.utc.iso8601,
  status: 'SCHEDULED',
  socialAccountTypes: ['INSTAGRAM'],
  data: {
    INSTAGRAM: {
      type: 'REEL',
      text: 'Posted with Ruby! #ruby #rails #automation',
      uploadIds: [upload['id']],
      shareToFeed: true
    }
  }
})

puts "Post created: #{post['id']}"

Post to TikTok

post = client.post('/post', {
  teamId: TEAM_ID,
  title: 'My TikTok Video',
  postDate: Time.now.utc.iso8601,
  status: 'SCHEDULED',
  socialAccountTypes: ['TIKTOK'],
  data: {
    TIKTOK: {
      type: 'VIDEO',
      text: 'Automated with Ruby! #fyp #ruby',
      uploadIds: [upload['id']],
      privacy: 'PUBLIC_TO_EVERYONE',
      disableComments: false,
      disableDuet: false,
      disableStitch: false
    }
  }
})

Post to Multiple Platforms

post = client.post('/post', {
  teamId: TEAM_ID,
  title: 'Cross-platform video',
  postDate: Time.now.utc.iso8601,
  status: 'SCHEDULED',
  socialAccountTypes: %w[TIKTOK INSTAGRAM YOUTUBE LINKEDIN TWITTER],
  data: {
    TIKTOK: {
      type: 'VIDEO',
      text: 'New video! #fyp #viral',
      uploadIds: [upload_id],
      privacy: 'PUBLIC_TO_EVERYONE'
    },
    INSTAGRAM: {
      type: 'REEL',
      text: 'Check out this Reel! #reels',
      uploadIds: [upload_id],
      shareToFeed: true
    },
    YOUTUBE: {
      type: 'SHORT',
      text: 'New YouTube Short',           # this is the video TITLE (max 100 chars)
      description: 'Subscribe for more.',   # max 5000 chars
      uploadIds: [upload_id],
      privacy: 'PUBLIC',                    # PUBLIC, PRIVATE, or UNLISTED
      madeForKids: false
    },
    LINKEDIN: {
      text: 'Excited to share this with my professional network!',
      uploadIds: [upload_id]
    },
    TWITTER: {
      text: 'New video just dropped!',
      uploadIds: [upload_id]
    }
  }
})

puts "Posted to #{post['socialAccountTypes'].length} platforms!"
# Upload multiple images first
image_ids = %w[./img1.jpg ./img2.jpg ./img3.jpg].map do |path|
  client.upload_file(path, TEAM_ID)['id']
end

post = client.post('/post', {
  teamId: TEAM_ID,
  title: 'My Carousel',
  postDate: Time.now.utc.iso8601,
  status: 'SCHEDULED',
  socialAccountTypes: ['INSTAGRAM'],
  data: {
    INSTAGRAM: {
      type: 'POST',
      text: 'Swipe through! #carousel',
      uploadIds: image_ids  # 2-10 items
    }
  }
})

Get Analytics

Analytics are available on paid tiers only. Not available for Twitter/X, Discord, or Slack. See the Analytics docs for per-platform availability.

Social Account Analytics

Analytics require both teamId and platformType. The response contains an items array of snapshots (newest last).
analytics = client.get('/analytics/social-account', {
  teamId: TEAM_ID,
  platformType: 'INSTAGRAM'
  # Valid: INSTAGRAM, FACEBOOK, LINKEDIN, TIKTOK, YOUTUBE,
  #        THREADS, PINTEREST, REDDIT, MASTODON, BLUESKY, GOOGLE_BUSINESS
})

# analytics['socialAccount'] - the social account object
# analytics['items']         - array of analytics snapshots (newest last)

latest = analytics['items'].last

puts "Followers: #{latest['followers']}"
puts "Impressions: #{latest['impressions']}"
puts "Likes: #{latest['likes']}"
puts "Comments: #{latest['comments']}"

Post Analytics

post_analytics = client.get('/analytics/post', {
  postId: 'POST_ID',
  platformType: 'INSTAGRAM'
})

# post_analytics['post']  - the post object
# post_analytics['items'] - array of analytics snapshots

latest = post_analytics['items'].last

puts "Views: #{latest['views']}"
puts "Likes: #{latest['likes']}"
puts "Comments: #{latest['comments']}"
puts "Shares: #{latest['shares']}"
puts "Saves: #{latest['saves']}"

Bulk Post Analytics

bulk = client.get('/analytics/post/bulk', {
  postIds: ['id1', 'id2', 'id3'],
  platformType: 'INSTAGRAM',
  limit: 20    # max 20 per page
})

# bulk['results']    - array of { postId, items, error }
# bulk['pagination'] - { page, limit, total, totalPages }

bulk['results'].each do |result|
  next if result['error']

  latest = result['items']&.last
  next unless latest

  puts "Post #{result['postId']}: #{latest['impressions']} impressions, #{latest['likes']} likes"
end

List and Manage Posts

# List posts
posts = client.get('/post', {
  teamId: TEAM_ID,
  status: 'SCHEDULED',    # DRAFT, SCHEDULED, POSTED, ERROR, PROCESSING, etc.
  orderBy: 'postDate',    # createdAt, updatedAt, postDate, postedDate
  order: 'DESC',
  limit: 20,
  offset: 0
})

posts['items'].each do |post|
  puts "#{post['title']} - #{post['postDate']} - #{post['status']}"
end

# Get single post
post = client.get("/post/#{post_id}")
puts post['title']

# Delete post
deleted = client.delete("/post/#{post_id}")
puts "Deleted: #{deleted['id']}"

Rails Integration

Service Object

# app/services/social_media_service.rb
class SocialMediaService
  def initialize
    @client = BundleSocialClient.new(Rails.application.credentials.bundlesocial[:api_key])
    @team_id = Rails.application.credentials.bundlesocial[:team_id]
  end

  def upload(file)
    # file can be an ActionDispatch::Http::UploadedFile or path string
    path = file.respond_to?(:path) ? file.path : file
    @client.upload_file(path, @team_id)
  end

  def create_post(platforms:, caption:, upload_ids:, schedule_at: Time.current)
    data = build_platform_data(platforms, caption, upload_ids)

    @client.post('/post', {
      teamId: @team_id,
      title: caption.truncate(50),
      postDate: schedule_at.utc.iso8601,
      status: 'SCHEDULED',
      socialAccountTypes: platforms,
      data: data
    })
  end

  def get_account_analytics(platform_type)
    @client.get('/analytics/social-account', {
      teamId: @team_id,
      platformType: platform_type
    })
  end

  def get_post_analytics(post_id, platform_type)
    @client.get('/analytics/post', {
      postId: post_id,
      platformType: platform_type
    })
  end

  private

  def build_platform_data(platforms, caption, upload_ids)
    platforms.each_with_object({}) do |platform, data|
      data[platform] = base_data(caption, upload_ids).merge(platform_specific(platform))
    end
  end

  def base_data(caption, upload_ids)
    { text: caption, uploadIds: upload_ids }
  end

  def platform_specific(platform)
    case platform
    when 'TIKTOK'
      { type: 'VIDEO', privacy: 'PUBLIC_TO_EVERYONE' }
    when 'INSTAGRAM'
      { type: 'REEL', shareToFeed: true }
    when 'YOUTUBE'
      { type: 'SHORT', privacy: 'PUBLIC', madeForKids: false }
    else
      {}
    end
  end
end

Controller

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def create
    service = SocialMediaService.new

    # Upload media
    upload = service.upload(params[:video])

    # Create cross-platform post
    post = service.create_post(
      platforms: params[:platforms],
      caption: params[:caption],
      upload_ids: [upload['id']],
      schedule_at: params[:schedule_at]&.to_datetime || Time.current
    )

    render json: { success: true, post_id: post['id'] }
  rescue StandardError => e
    render json: { error: e.message }, status: :unprocessable_entity
  end
end

Background Job (Sidekiq)

# app/jobs/scheduled_post_job.rb
class ScheduledPostJob
  include Sidekiq::Job

  def perform(video_path, platforms, caption, schedule_at)
    service = SocialMediaService.new

    # Upload
    upload = service.upload(video_path)

    # Schedule post
    post = service.create_post(
      platforms: platforms,
      caption: caption,
      upload_ids: [upload['id']],
      schedule_at: Time.parse(schedule_at)
    )

    Rails.logger.info "Post scheduled: #{post['id']}"
  end
end

# Usage
ScheduledPostJob.perform_async(
  '/path/to/video.mp4',
  %w[INSTAGRAM TIKTOK],
  'Automated post! #rails',
  1.day.from_now.iso8601
)

Active Record Integration

# app/models/social_post.rb
class SocialPost < ApplicationRecord
  has_one_attached :video

  enum :status, { draft: 0, scheduled: 1, published: 2, failed: 3 }

  after_commit :schedule_publishing, on: :create, if: :scheduled?

  private

  def schedule_publishing
    PublishSocialPostJob.perform_at(scheduled_at, id)
  end
end

# app/jobs/publish_social_post_job.rb
class PublishSocialPostJob
  include Sidekiq::Job

  def perform(social_post_id)
    post = SocialPost.find(social_post_id)
    service = SocialMediaService.new

    # Download attached video to temp file
    video_path = download_to_temp(post.video)

    begin
      upload = service.upload(video_path)

      result = service.create_post(
        platforms: post.platforms,
        caption: post.caption,
        upload_ids: [upload['id']]
      )

      post.update!(
        status: :published,
        external_id: result['id'],
        published_at: Time.current
      )
    rescue => e
      post.update!(status: :failed, error_message: e.message)
      raise
    ensure
      File.delete(video_path) if File.exist?(video_path)
    end
  end

  private

  def download_to_temp(attachment)
    path = Rails.root.join('tmp', attachment.filename.to_s)
    File.open(path, 'wb') { |f| f.write(attachment.download) }
    path.to_s
  end
end

Error Handling

begin
  post = client.post('/post', post_data)
  puts "Success: #{post['id']}"
rescue StandardError => e
  case e.message
  when /400/
    Rails.logger.error "Validation error: #{e.message}"
  when /401/
    Rails.logger.error "Invalid API key"
  when /429/
    Rails.logger.error "Rate limited, retry later"
    sleep(2)
    retry
  else
    Rails.logger.error "Unexpected error: #{e.message}"
    Sentry.capture_exception(e) if defined?(Sentry)
  end
end
For full error codes and what they mean, see the Errors reference. We return verbose error messages with platform-specific details so you don’t have to guess what went wrong.

Configuration (Rails)

# config/credentials.yml.enc
bundlesocial:
  api_key: your_api_key_here
  team_id: your_team_id_here
# Or use environment variables
# config/initializers/bundlesocial.rb
Rails.application.config.bundlesocial = {
  api_key: ENV['BUNDLESOCIAL_API_KEY'],
  team_id: ENV['BUNDLESOCIAL_TEAM_ID']
}

Rake Task Example

# lib/tasks/social_media.rake
namespace :social_media do
  desc 'Post daily content to all platforms'
  task daily_post: :environment do
    service = SocialMediaService.new

    video_path = Rails.root.join('content', 'daily', "#{Date.current}.mp4")

    unless File.exist?(video_path)
      puts 'No video found for today'
      exit
    end

    upload = service.upload(video_path.to_s)

    post = service.create_post(
      platforms: %w[INSTAGRAM TIKTOK YOUTUBE],
      caption: "Daily content - #{Date.current.strftime('%B %d, %Y')} #daily",
      upload_ids: [upload['id']],
      schedule_at: Date.current.noon
    )

    puts "Scheduled post: #{post['id']}"
  end

  desc 'Fetch analytics for all connected accounts'
  task sync_analytics: :environment do
    service = SocialMediaService.new

    SocialAccount.find_each do |account|
      analytics = service.get_account_analytics(account.platform)

      latest = analytics['items']&.last
      next unless latest

      account.update!(
        followers: latest['followers'],
        impressions: latest['impressions'],
        last_synced_at: Time.current
      )

      puts "Synced: #{account.platform} - #{latest['followers']} followers"
    end
  end
end

Resources

API Documentation

Full API reference with all endpoints

GitHub Examples

Working code samples in multiple languages