Instagram Reels Implementation Brief

Complete technical spec for adding Reels publishing to Stylify — Phase 2 feature

Prepared by: Charlotte (COO) Date: 2026-02-24 Status: Pre-work complete, ready when prioritized Estimated effort: 15-25 hours

Executive Summary

Instagram Reels are the highest-engagement content format on the platform. For hair stylists specifically, Reels showing transformations, techniques, and behind-the-chair moments drive significantly more reach than static image posts. Adding Reels support to Stylify would be a strong differentiator.

The good news: our existing Instagram Graph API permissions already cover Reels publishing. The instagram_business_content_publish scope we're requesting in our Meta App Review includes Reels. No additional approval cycle needed.

The work breaks into five areas, ordered by dependency:

#Work AreaWhoEffortStatus
1Video storage infrastructure (S3/R2)Stitch~3-4 hrsNEW
2Backend Reels publishing flowStitch~4-6 hrsMODIFY
3Database migration for video metadataStitch~1-2 hrsNEW
4Frontend video upload + preview UIStitch~4-6 hrsNEW
5AI caption generation for ReelsStitch~2-3 hrsMODIFY
What Charlotte has pre-built in this document Complete API spec with exact endpoints, parameters, and error codes. Video format requirements. Storage architecture recommendation with cost analysis. Database migration spec. Publishing flow comparison (images vs. Reels). Server-side validation rules. UX considerations for stylists. Everything Stitch needs to start building without additional research.

Current Image Flow vs. Reels Flow

The Instagram Graph API uses the same 3-step container model for both images and Reels. The differences are in the parameters and the addition of a status polling step for video.

Current: Image Publishing

1
Create container — POST to /{user_id}/media with image_url + caption
2
Publish — POST to /{user_id}/media_publish with creation_id. Nearly instant.

New: Reels Publishing

1
Upload video to S3/R2, get public URL
2
Create container — POST to /{user_id}/media with media_type=REELS + video_url + caption
3
Poll status — GET /{container_id}?fields=status_code until FINISHED (new step)
4
Publish — POST to /{user_id}/media_publish with creation_id

The key differences: Reels require (a) a publicly accessible video URL (not a direct upload), (b) an explicit media_type=REELS parameter, and (c) an async polling step because Instagram needs time to download and process the video before it can be published.

1. Instagram Graph API — Reels Endpoints

Step 1: Create Container

POST https://graph.instagram.com/v25.0/{IG_USER_ID}/media

Parameters:
  media_type:    "REELS"              (required)
  video_url:     "{PUBLIC_VIDEO_URL}"  (required — must be publicly accessible)
  caption:       "Caption text..."     (optional, max 2200 chars)
  cover_url:     "{PUBLIC_JPEG_URL}"   (optional — custom cover image)
  thumb_offset:  5000                  (optional — cover frame at 5s, in ms)
  share_to_feed: true                  (optional — show in Feed + Reels tab)
  collaborators: ["username1"]         (optional — up to 3 collaborators)
  access_token:  "{USER_ACCESS_TOKEN}"

Response:
  { "id": "{IG_CONTAINER_ID}" }

Step 2: Poll Container Status

GET https://graph.instagram.com/v25.0/{IG_CONTAINER_ID}?fields=status_code
    &access_token={USER_ACCESS_TOKEN}

Response:
  { "status_code": "IN_PROGRESS" }  ← keep polling (max 1x/min)
  { "status_code": "FINISHED" }     ← ready to publish
  { "status_code": "ERROR" }        ← failed, retry with new container
  { "status_code": "EXPIRED" }      ← >24 hrs, create new container

Step 3: Publish

POST https://graph.instagram.com/v25.0/{IG_USER_ID}/media_publish

Parameters:
  creation_id:   "{IG_CONTAINER_ID}"
  access_token:  "{USER_ACCESS_TOKEN}"

Response:
  { "id": "{MEDIA_ID}" }  ← published Reel

Rate Limit Check (recommended before publishing)

GET https://graph.instagram.com/v25.0/{IG_USER_ID}/content_publishing_limit
    ?fields=config,quota_usage
    &access_token={USER_ACCESS_TOKEN}
Critical: Video URL Requirements Instagram's servers will HTTP GET (cURL) the video from the URL you provide. The URL must be publicly accessible at the time of the API call, must use US-ASCII characters only, and must not expire before Instagram finishes downloading (allow at least 1 hour for large files). For S3 presigned URLs, set expiration to 24 hours minimum.

2. Video Format Requirements

RequirementSpecificationNotes
Container formatMP4 or MOVmoov atom must be at front of file (use -movflags +faststart in ffmpeg)
Video codecH.264 (preferred) or HEVCProgressive scan, closed GOP, 4:2:0 chroma subsampling
Audio codecAAC48kHz max sample rate, mono or stereo
Frame rate23-60 FPS30 FPS recommended
ResolutionMax 1920px on longest side1080x1920 (9:16) recommended for Reels
Aspect ratio0.01:1 to 10:19:16 strongly recommended — other ratios get cropped/letterboxed
Bitrate25 Mbps max (VBR)8-12 Mbps typical for quality Reels
Audio bitrate128 kbps
Duration3 seconds min, 15 minutes maxEngagement sweet spot: 15-60 seconds for stylists
File size300 MB maxTypical 30-second Reel at 10 Mbps ≈ 37 MB

Cover Image Requirements (if using cover_url)

RequirementSpecification
FormatJPEG only
Max file size8 MB
Aspect ratio9:16 recommended (otherwise cropped to center)
Color spacesRGB
If both cover_url and thumb_offset are provided, cover_url wins. Implement as a user choice: either upload a custom cover image, OR pick a frame from the video (thumb_offset in milliseconds). Don't send both.

3. Server-Side Video Validation

Before uploading to storage or sending to Instagram, validate on our backend. This prevents wasted storage costs and gives the user a fast, clear error instead of a cryptic Instagram API failure.

// services/videoValidator.js — Pre-upload validation

import { execSync } from 'child_process';

const REELS_CONSTRAINTS = {
  maxFileSizeBytes: 300 * 1024 * 1024,  // 300 MB
  maxDurationSec: 900,                   // 15 minutes
  minDurationSec: 3,
  allowedCodecs: ['h264', 'hevc'],
  allowedContainers: ['mov', 'mp4'],
  maxWidth: 1920,
  maxHeight: 1920,
  minFps: 23,
  maxFps: 60,
  recommendedWidth: 1080,
  recommendedHeight: 1920,
};

function validateVideo(filePath) {
  const errors = [];
  const warnings = [];

  // File size check (fast, no ffprobe needed)
  const stats = fs.statSync(filePath);
  if (stats.size > REELS_CONSTRAINTS.maxFileSizeBytes) {
    errors.push(`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB (max 300MB)`);
    return { valid: false, errors, warnings };
  }

  // ffprobe metadata extraction
  const probe = JSON.parse(execSync(
    `ffprobe -v quiet -print_format json -show_streams -show_format "${filePath}"`
  ).toString());

  const videoStream = probe.streams.find(s => s.codec_type === 'video');
  const audioStream = probe.streams.find(s => s.codec_type === 'audio');
  const duration = parseFloat(probe.format.duration);

  // Duration
  if (duration < REELS_CONSTRAINTS.minDurationSec) {
    errors.push(`Too short: ${duration.toFixed(1)}s (minimum 3s)`);
  }
  if (duration > REELS_CONSTRAINTS.maxDurationSec) {
    errors.push(`Too long: ${duration.toFixed(0)}s (maximum 15 min)`);
  }
  if (duration > 60) {
    warnings.push('Reels over 60 seconds get less reach. 15-60s is the sweet spot.');
  }

  // Codec
  if (videoStream && !REELS_CONSTRAINTS.allowedCodecs.includes(videoStream.codec_name)) {
    errors.push(`Unsupported codec: ${videoStream.codec_name} (need H.264 or HEVC)`);
  }

  // Resolution
  if (videoStream) {
    const { width, height } = videoStream;
    if (width > REELS_CONSTRAINTS.maxWidth || height > REELS_CONSTRAINTS.maxHeight) {
      errors.push(`Resolution too high: ${width}x${height} (max 1920px)`);
    }
    if (width > height) {
      warnings.push(`Landscape video (${width}x${height}) — will be letterboxed. Vertical 9:16 recommended.`);
    }
    if (width === height) {
      warnings.push(`Square video (${width}x${height}) — will have bars. Vertical 9:16 recommended.`);
    }
  }

  // Frame rate
  if (videoStream) {
    const fps = eval(videoStream.r_frame_rate); // "30/1" → 30
    if (fps < REELS_CONSTRAINTS.minFps) errors.push(`FPS too low: ${fps} (minimum 23)`);
    if (fps > REELS_CONSTRAINTS.maxFps) errors.push(`FPS too high: ${fps} (maximum 60)`);
  }

  // Audio check
  if (!audioStream) {
    warnings.push('No audio track detected. Reels with audio get significantly more engagement.');
  }

  return {
    valid: errors.length === 0,
    errors,
    warnings,
    metadata: {
      duration,
      width: videoStream?.width,
      height: videoStream?.height,
      codec: videoStream?.codec_name,
      fps: videoStream ? eval(videoStream.r_frame_rate) : null,
      fileSizeMB: (stats.size / 1024 / 1024).toFixed(1),
      hasAudio: !!audioStream,
    }
  };
}
User-Friendly Error Messages Every validation error above maps to a plain-English message Stylify can show in a toast. No cryptic Instagram error codes reaching the user. Example: "Your video is 18 minutes long — Instagram Reels max out at 15 minutes. Try trimming it down."

Optional: Server-Side Transcoding

If a user uploads a video in the wrong format (e.g., AVI, WebM, wrong codec), we could transcode it server-side using ffmpeg. This is a "nice to have" that adds complexity and server CPU cost. For v1, I'd recommend rejecting non-compliant videos with a clear error and adding transcoding later if users frequently hit format issues.

// If we add transcoding later:
// ffmpeg -i input.avi -c:v libx264 -preset medium -crf 23
//        -c:a aac -b:a 128k -movflags +faststart
//        -vf "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2"
//        output.mp4

4. Video Storage Architecture

Instagram requires a publicly accessible URL to download the video. We need cloud storage. Here's the comparison:

OptionCost (at Stylify scale)ProsCons
Cloudflare R2 (recommended) $0.015/GB/month storage
$0 egress (free!)
Zero egress fees (Instagram downloads = free). S3-compatible API. Generous free tier (10GB/month storage, 10M requests). Newer service, slightly less ecosystem support
AWS S3 $0.023/GB/month storage
$0.09/GB egress
Industry standard, massive ecosystem, presigned URL support Egress fees add up — every Instagram download costs money
Supabase Storage Included in Pro plan (250GB bandwidth) Already in our stack, no new service to manage 250GB bandwidth limit on Pro plan, not designed for high-throughput media delivery

Recommendation: Cloudflare R2

The zero egress cost is the deciding factor. Every time Instagram downloads a video from our storage, that's egress. With S3, a 50MB video downloaded = $0.0045 per publish. Sounds tiny, but at 500 users × 1 Reel/day = $67.50/month just in download fees. R2: $0.

Cost Projection

Storage per Reel (avg 40MB, kept 30 days)~$0.0006/reel
100 users × 1 reel/day × 30 days = 120GB~$1.80/month
500 users × 1 reel/day × 30 days = 600GB~$9.00/month
Egress (Instagram downloads)$0 (R2 free egress)
Estimated monthly cost at 100 users~$2/month

Storage Lifecycle

Videos only need to stay in storage long enough for Instagram to download them. After successful publish, the video could be deleted. Recommended: keep for 7 days (allows retry if publish fails), then auto-delete via R2 lifecycle policy.

// R2 lifecycle rule (set via Cloudflare dashboard or API)
{
  "rules": [{
    "id": "auto-cleanup-published-reels",
    "status": "Enabled",
    "filter": { "prefix": "reels/" },
    "expiration": { "days": 7 }
  }]
}

Upload Flow

// services/videoStorage.js — R2 Upload (S3-compatible)
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const r2 = new S3Client({
  region: 'auto',
  endpoint: process.env.R2_ENDPOINT,    // https://{account_id}.r2.cloudflarestorage.com
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
});

const BUCKET = process.env.R2_BUCKET_NAME || 'stylify-media';

async function uploadVideo(fileBuffer, userId, filename) {
  const key = `reels/${userId}/${Date.now()}_${filename}`;

  await r2.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: fileBuffer,
    ContentType: 'video/mp4',
  }));

  // Generate public URL for Instagram to download
  // Option A: R2 public bucket (simplest)
  const publicUrl = `https://${BUCKET}.${process.env.R2_PUBLIC_DOMAIN}/${key}`;

  // Option B: Presigned URL (more secure, time-limited)
  // const publicUrl = await getSignedUrl(r2, new GetObjectCommand({
  //   Bucket: BUCKET, Key: key
  // }), { expiresIn: 86400 }); // 24 hours

  return { key, publicUrl };
}

5. Database Migration

The posts table needs to support video metadata alongside existing image data. This is an additive migration — no existing columns change.

-- Migration: Add video/Reels support to posts table

ALTER TABLE posts
  ADD COLUMN media_type VARCHAR(10) DEFAULT 'IMAGE',  -- 'IMAGE' or 'REELS'
  ADD COLUMN video_url TEXT,                            -- R2 public URL
  ADD COLUMN video_storage_key TEXT,                    -- R2 object key (for cleanup)
  ADD COLUMN video_duration_sec DECIMAL(6,1),           -- Duration in seconds
  ADD COLUMN video_width INTEGER,                       -- Width in pixels
  ADD COLUMN video_height INTEGER,                      -- Height in pixels
  ADD COLUMN video_file_size_mb DECIMAL(6,1),           -- File size in MB
  ADD COLUMN cover_url TEXT,                             -- Custom cover image URL (optional)
  ADD COLUMN thumb_offset_ms INTEGER,                    -- Cover frame offset in ms (optional)
  ADD COLUMN container_status VARCHAR(20),               -- IN_PROGRESS, FINISHED, ERROR, EXPIRED
  ADD COLUMN container_id TEXT;                           -- Instagram container ID (for polling)

-- Index for cleanup job (find videos older than 7 days that have been published)
CREATE INDEX idx_posts_video_cleanup
  ON posts (media_type, created_at)
  WHERE media_type = 'REELS' AND video_storage_key IS NOT NULL;

-- Update the existing posts to explicitly mark as IMAGE
UPDATE posts SET media_type = 'IMAGE' WHERE media_type IS NULL;
Migration Safety This is purely additive — adds columns with defaults, creates an index, backfills NULLs. No existing data changes. Safe to run during production freeze once Meta approves. Existing image publishing continues to work unchanged.

6. Backend Reels Publishing Flow

The contentService needs a new publishing path for Reels. The key addition is the async container polling loop.

// services/reelsPublisher.js — Reels-specific publishing logic

import { cache } from './cache.js';

const POLL_INTERVAL_MS = 10000;    // 10 seconds between polls
const MAX_POLL_ATTEMPTS = 30;      // 5 minutes max (30 × 10s)

async function publishReel({ userId, videoUrl, caption, coverUrl, thumbOffsetMs, accessToken }) {
  // Step 1: Check rate limit before attempting
  const rateCheck = await checkPublishingLimit(userId, accessToken);
  if (!rateCheck.allowed) {
    throw new Error(`Publishing limit reached. Resets in ${rateCheck.resetInMinutes} minutes.`);
  }

  // Step 2: Create container
  const containerParams = {
    media_type: 'REELS',
    video_url: videoUrl,
    caption: caption,
    share_to_feed: true,
    access_token: accessToken,
  };

  // Add optional cover (user choice: custom image OR frame selection, not both)
  if (coverUrl) {
    containerParams.cover_url = coverUrl;
  } else if (thumbOffsetMs !== undefined && thumbOffsetMs !== null) {
    containerParams.thumb_offset = thumbOffsetMs;
  }

  const containerRes = await fetch(
    `https://graph.instagram.com/v25.0/${userId}/media`,
    { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(containerParams) }
  );

  const containerData = await containerRes.json();
  if (containerData.error) {
    throw new Error(`Container creation failed: ${containerData.error.message}`);
  }

  const containerId = containerData.id;

  // Step 3: Poll for processing completion
  let status = 'IN_PROGRESS';
  let attempts = 0;

  while (status === 'IN_PROGRESS' && attempts < MAX_POLL_ATTEMPTS) {
    await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
    attempts++;

    const statusRes = await fetch(
      `https://graph.instagram.com/v25.0/${containerId}?fields=status_code&access_token=${accessToken}`
    );
    const statusData = await statusRes.json();
    status = statusData.status_code;
  }

  if (status !== 'FINISHED') {
    throw new Error(`Video processing failed with status: ${status}`);
  }

  // Step 4: Publish
  const publishRes = await fetch(
    `https://graph.instagram.com/v25.0/${userId}/media_publish`,
    { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ creation_id: containerId, access_token: accessToken }) }
  );

  const publishData = await publishRes.json();
  if (publishData.error) {
    throw new Error(`Publish failed: ${publishData.error.message}`);
  }

  return { mediaId: publishData.id, containerId };
}

async function checkPublishingLimit(userId, accessToken) {
  const res = await fetch(
    `https://graph.instagram.com/v25.0/${userId}/content_publishing_limit?fields=config,quota_usage&access_token=${accessToken}`
  );
  const data = await res.json();
  // Parse quota_usage vs config to determine if allowed
  return { allowed: true, resetInMinutes: 0 }; // Implement based on response shape
}

Scheduled Publishing Integration

The existing cron job (60-second interval) that handles scheduled image posts needs to be extended. For Reels, the flow is longer because of the polling step, so the cron should:

StepCron Cycle 1Cron Cycles 2-N
Image postCreate container → publish (instant)N/A
Reel (new)Create container → save container_id to DB → set status IN_PROGRESSPoll status → if FINISHED, publish → update status

This means the cron picks up Reels that are IN_PROGRESS on subsequent cycles and checks their status, rather than blocking for 5 minutes in a single cycle.

7. Error Codes & User-Friendly Messages

API ErrorCodeUser-Facing Message
Video download timeout2207003"Your video is taking too long to upload. Try a smaller file or check your connection."
Media expired2207020"This video expired before it could be posted. Let's try again."
Upload error2207053"Something went wrong uploading your video. Let's try one more time."
Invalid thumbnail offset2207057"The cover frame you picked is outside the video. Try choosing a different moment."
Publishing limit reached2207042"You've hit Instagram's daily posting limit. Your Reel will be queued for tomorrow."
Account restricted2207050"Instagram has restricted your account. You may need to verify your identity in the Instagram app."
Unknown media type2207023(Internal error — should never reach user. Log and alert.)
Generic server error2207001"Instagram is having a moment. We'll retry automatically."

8. What Stays the Same

A lot of the existing Stylify architecture carries over without modification:

ComponentStatusNotes
AI caption generation (Claude Haiku)REUSESame voice archetypes, same caption quality. Prompt just needs a "this is for a Reel" context hint.
Hashtag generationREUSESame 5-hashtag limit, same local generation logic
Feature gate middlewareREUSEReels could be Pro-only or available to all tiers (decision needed)
2-minute approval loopREUSEUser still approves caption + cover before publishing
1 post/day/user constraintREUSEA Reel counts as that day's post (unless we decide otherwise)
Rate limiting (100 gen/24hr)REUSECaption generation limit unchanged
Kit lifecycle tagsMINORAdd a "published-reel" tag for analytics segmentation
Instagram permissionsREUSESame instagram_business_content_publish scope covers Reels

9. UX Considerations for Stylists

How Stylists Create Reels Today (Without Stylify)

Understanding this helps us design the right flow. Most stylists: record a transformation video on their phone (before/during/after), maybe add music in Instagram or CapCut, then spend 15-30 minutes agonizing over the caption. Stylify's value proposition for Reels is the same as for images — we handle the caption so they can focus on the visual content they're already good at.

Proposed User Flow

1
Upload video — Stylist uploads their Reel video from phone/camera roll. Show file size, duration, and a "looks good" / "needs fixing" indicator based on validation.
2
Video preview + cover selection — Show video preview. Let them either pick a frame from the video (scrubber) or upload a custom cover image. Default to 0s if they skip.
3
Caption generation — Same flow as today: Stylify generates caption in their voice, with hashtags. Add a "Reel" context hint to the prompt (e.g., "This caption is for a short-form video Reel showing [transformation/technique/etc]").
4
Review + approve — The sacred 2-minute approval loop. Stylist sees: video thumbnail, caption, hashtags, scheduled time. Tap to approve or edit.
5
Publish / schedule — Same as today. If publishing now, show a "Processing your Reel..." state (since video takes longer than images). If scheduling, same as current flow.

Key UX Decision: Video Content Type Selection

When a user starts creating a post, they need to choose between uploading a photo or a video. This could be:

OptionUXRecommendation
Auto-detectSingle upload button, detect file typeSimplest for user. Recommended for v1.
Explicit toggle"Photo" / "Reel" tabs on create screenClearer but adds a decision point.

Processing State

Unlike images (near-instant), Reels take 10 seconds to a few minutes to process on Instagram's side. The UI needs a clear processing state: "Your Reel is processing... this usually takes about a minute." Avoid making the user wait on a loading screen — show a progress indicator and let them navigate away, with a notification when it's live.

10. Limitations — What the API Cannot Do

FeatureAvailable via API?Workaround
Add music/audio tracksNoAudio must be embedded in video file before upload. Stylists typically add music in CapCut or Instagram's native editor before uploading to Stylify.
Apply Instagram filters/effectsNoNot available. Filters must be applied before upload.
Include Reels in carousel postsNoInstagram doesn't support Reels in carousels via API. Separate content types.
Edit after publishingNoMust delete and re-publish. Instagram API doesn't support post-publish editing of Reels.
Add interactive stickersNoStickers (polls, questions, countdowns) are app-only.
Video trimming/editingNoWould need to build this in-app, or users trim before uploading
Key Messaging Implication We should position Stylify's Reels support as "upload your video, we handle the caption" — not "create Reels in Stylify." The video creation happens on the stylist's phone. Stylify adds the voice-matched caption, hashtags, and scheduling. This matches our existing value prop and sets honest expectations.

11. Decisions Needed Before Building

These are the open questions that need Jason's input before Stitch starts:

#DecisionOptionsCharlotte's Recommendation
1Which tiers get Reels?All tiers / Pro+Salon only / Salon onlyAll tiers. Reels are how stylists grow on Instagram — gating them would hurt our value prop.
2Does a Reel count as the daily post?Yes (1 post/day total) / No (1 image + 1 reel/day)Yes for v1. Keep it simple. Revisit if users ask for both.
3Server-side transcoding?Yes (accept any video format) / No (reject non-MP4)No for v1. Reject with clear error. Add transcoding later if needed.
4Storage provider?Cloudflare R2 / AWS S3 / Supabase StorageCloudflare R2. Zero egress fees. ~$2/month at 100 users.
5Cover image: custom upload or frame picker?Frame picker only / Both / Skip (default to first frame)Frame picker only for v1. Simple slider, low effort. Add custom cover upload later.
6When to build?Immediately post-Meta / After paying users / After specific milestoneAfter launch stabilizes and we have 10+ active users posting images. Prove the core loop first.

12. Implementation Checklist for Stitch

(When the time comes — not before the decisions above are made.)

Phase 1: Storage + Backend (do first)

Phase 2: Frontend (do second)

Phase 3: AI + Testing (do third)

Assumptions & Uncertainties

Assumption: Railway supports ffmpeg/ffprobe installation (needed for server-side video validation). Most Railway Node.js images include ffmpeg, but this should be verified. If not available, a static ffmpeg binary can be bundled or validation can be done client-side only.

Uncertainty: Instagram's video processing time varies widely (10 seconds for a short clip, several minutes for a 15-minute video). Our polling implementation needs real-world testing to calibrate the timeout and retry behavior. The 5-minute max polling window recommended by Instagram may not be sufficient for very long videos.