Complete technical spec for adding Reels publishing to Stylify — Phase 2 feature
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 Area | Who | Effort | Status |
|---|---|---|---|---|
| 1 | Video storage infrastructure (S3/R2) | Stitch | ~3-4 hrs | NEW |
| 2 | Backend Reels publishing flow | Stitch | ~4-6 hrs | MODIFY |
| 3 | Database migration for video metadata | Stitch | ~1-2 hrs | NEW |
| 4 | Frontend video upload + preview UI | Stitch | ~4-6 hrs | NEW |
| 5 | AI caption generation for Reels | Stitch | ~2-3 hrs | MODIFY |
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.
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.
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}" }
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
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
GET https://graph.instagram.com/v25.0/{IG_USER_ID}/content_publishing_limit
?fields=config,quota_usage
&access_token={USER_ACCESS_TOKEN}
| Requirement | Specification | Notes |
|---|---|---|
| Container format | MP4 or MOV | moov atom must be at front of file (use -movflags +faststart in ffmpeg) |
| Video codec | H.264 (preferred) or HEVC | Progressive scan, closed GOP, 4:2:0 chroma subsampling |
| Audio codec | AAC | 48kHz max sample rate, mono or stereo |
| Frame rate | 23-60 FPS | 30 FPS recommended |
| Resolution | Max 1920px on longest side | 1080x1920 (9:16) recommended for Reels |
| Aspect ratio | 0.01:1 to 10:1 | 9:16 strongly recommended — other ratios get cropped/letterboxed |
| Bitrate | 25 Mbps max (VBR) | 8-12 Mbps typical for quality Reels |
| Audio bitrate | 128 kbps | |
| Duration | 3 seconds min, 15 minutes max | Engagement sweet spot: 15-60 seconds for stylists |
| File size | 300 MB max | Typical 30-second Reel at 10 Mbps ≈ 37 MB |
| Requirement | Specification |
|---|---|
| Format | JPEG only |
| Max file size | 8 MB |
| Aspect ratio | 9:16 recommended (otherwise cropped to center) |
| Color space | sRGB |
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,
}
};
}
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
Instagram requires a publicly accessible URL to download the video. We need cloud storage. Here's the comparison:
| Option | Cost (at Stylify scale) | Pros | Cons |
|---|---|---|---|
| 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 |
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.
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 }
}]
}
// 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 };
}
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;
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
}
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:
| Step | Cron Cycle 1 | Cron Cycles 2-N |
|---|---|---|
| Image post | Create container → publish (instant) | N/A |
| Reel (new) | Create container → save container_id to DB → set status IN_PROGRESS | Poll 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.
| API Error | Code | User-Facing Message |
|---|---|---|
| Video download timeout | 2207003 | "Your video is taking too long to upload. Try a smaller file or check your connection." |
| Media expired | 2207020 | "This video expired before it could be posted. Let's try again." |
| Upload error | 2207053 | "Something went wrong uploading your video. Let's try one more time." |
| Invalid thumbnail offset | 2207057 | "The cover frame you picked is outside the video. Try choosing a different moment." |
| Publishing limit reached | 2207042 | "You've hit Instagram's daily posting limit. Your Reel will be queued for tomorrow." |
| Account restricted | 2207050 | "Instagram has restricted your account. You may need to verify your identity in the Instagram app." |
| Unknown media type | 2207023 | (Internal error — should never reach user. Log and alert.) |
| Generic server error | 2207001 | "Instagram is having a moment. We'll retry automatically." |
A lot of the existing Stylify architecture carries over without modification:
| Component | Status | Notes |
|---|---|---|
| AI caption generation (Claude Haiku) | REUSE | Same voice archetypes, same caption quality. Prompt just needs a "this is for a Reel" context hint. |
| Hashtag generation | REUSE | Same 5-hashtag limit, same local generation logic |
| Feature gate middleware | REUSE | Reels could be Pro-only or available to all tiers (decision needed) |
| 2-minute approval loop | REUSE | User still approves caption + cover before publishing |
| 1 post/day/user constraint | REUSE | A Reel counts as that day's post (unless we decide otherwise) |
| Rate limiting (100 gen/24hr) | REUSE | Caption generation limit unchanged |
| Kit lifecycle tags | MINOR | Add a "published-reel" tag for analytics segmentation |
| Instagram permissions | REUSE | Same instagram_business_content_publish scope covers Reels |
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.
When a user starts creating a post, they need to choose between uploading a photo or a video. This could be:
| Option | UX | Recommendation |
|---|---|---|
| Auto-detect | Single upload button, detect file type | Simplest for user. Recommended for v1. |
| Explicit toggle | "Photo" / "Reel" tabs on create screen | Clearer but adds a decision point. |
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.
| Feature | Available via API? | Workaround |
|---|---|---|
| Add music/audio tracks | No | Audio 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/effects | No | Not available. Filters must be applied before upload. |
| Include Reels in carousel posts | No | Instagram doesn't support Reels in carousels via API. Separate content types. |
| Edit after publishing | No | Must delete and re-publish. Instagram API doesn't support post-publish editing of Reels. |
| Add interactive stickers | No | Stickers (polls, questions, countdowns) are app-only. |
| Video trimming/editing | No | Would need to build this in-app, or users trim before uploading |
These are the open questions that need Jason's input before Stitch starts:
| # | Decision | Options | Charlotte's Recommendation |
|---|---|---|---|
| 1 | Which tiers get Reels? | All tiers / Pro+Salon only / Salon only | All tiers. Reels are how stylists grow on Instagram — gating them would hurt our value prop. |
| 2 | Does 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. |
| 3 | Server-side transcoding? | Yes (accept any video format) / No (reject non-MP4) | No for v1. Reject with clear error. Add transcoding later if needed. |
| 4 | Storage provider? | Cloudflare R2 / AWS S3 / Supabase Storage | Cloudflare R2. Zero egress fees. ~$2/month at 100 users. |
| 5 | Cover 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. |
| 6 | When to build? | Immediately post-Meta / After paying users / After specific milestone | After launch stabilizes and we have 10+ active users posting images. Prove the core loop first. |
(When the time comes — not before the decisions above are made.)
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.