Back to Blog
Guide

Social Media Reporting for Agencies: Stop Wasting 20 Hours Per Month on Reports

November 1, 2025
21 min read
By SociaVault Team
Agency ReportingSocial Media ReportsClient ReportingMarketing AutomationAgency Tools

Social Media Reporting for Agencies: Stop Wasting 20 Hours Per Month on Reports

It's 11 PM on a Sunday.

You're pulling data from 15 different Instagram accounts. Then TikTok. Then LinkedIn. Copy-pasting numbers into Excel. Making charts. Exporting PDFs.

The client call is at 9 AM Monday. You've been doing this for 3 hours. You still have 12 more clients to go.

This isn't marketing. This is data entry.

And you're billing $150/hour for... data entry?

Meanwhile, your competitor automated their entire reporting process. They generate all their client reports in 20 minutes. They're spending those saved hours actually growing their clients' accounts.

Guess who's signing more clients?

This guide shows you how to build an automated social media reporting system that:

  • Pulls data automatically from Instagram, TikTok, LinkedIn (no manual exports)
  • Generates branded PDF reports with your agency's look
  • Emails reports to clients automatically every month
  • Tracks performance trends over time
  • Saves 15-20 hours per month (that's $2,000-3,000 in billable time)

Real code. Real PDFs. Real freedom from Sunday night report marathons.

The Agency Reporting Nightmare

Let's be brutally honest about how most agencies handle reporting:

The Manual Process (20 hours/month)

For each client:

  1. Log into Instagram → Go to Insights → Screenshot everything → Copy to Excel
  2. Log into TikTok → Analytics → More screenshots → More Excel
  3. Log into LinkedIn → Company analytics → Guess what? Screenshots.
  4. Open the template → Paste numbers → Update charts → Fix broken formulas
  5. Make it pretty → Adjust colors → Add client logo → Export PDF
  6. Email the client → Write a summary → Attach PDF → Send
  7. Repeat for 15 clients

Time per client: 60-90 minutes Time for 15 clients: 15-22.5 hours Your hourly rate: $150/hour Cost to your agency: $2,250-3,375/month in lost billable time

And the worst part? Clients don't even read most of these reports. They want:

  • "Are we growing?"
  • "What's working?"
  • "What should we do differently?"

They don't care about your beautiful Excel charts.

What Clients Actually Want

Clients care about:

  1. Growth metrics (followers, engagement, reach)
  2. Top performing content (what's working?)
  3. Trends (up or down?)
  4. Recommendations (what next?)
  5. ROI (if they're paying for ads)

Clients don't care about:

  • 50 charts with every possible metric
  • Raw numbers without context
  • Data dumps with no insights
  • Reports that look the same every month

The solution? Automate the data collection. Spend your time on insights and strategy.

What Automated Reporting Looks Like

The automated process (20 minutes/month):

  1. Cron job runs (every month on the 1st)
  2. Pulls data automatically from all client accounts (Instagram, TikTok, LinkedIn)
  3. Generates reports with your branded template
  4. Emails clients with PDF attached and summary
  5. You review and add insights (the only manual part - 20 minutes)

Time per client: ~1-2 minutes (automated) + 5 minutes (insights) Time for 15 clients: ~20 minutes + 75 minutes insights = 95 minutes total Time saved: 18.5 hours/month Billable hours reclaimed: $2,775/month

And the best part? Reports are consistent, timely, and professional. No more Sunday night panic sessions.

Architecture: Multi-Client Reporting System

Here's how to build this:

Database Schema

-- Clients
CREATE TABLE clients (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255),
  logo_url VARCHAR(500),
  primary_color VARCHAR(7) DEFAULT '#3b82f6',
  created_at TIMESTAMP DEFAULT NOW()
);

-- Social accounts for each client
CREATE TABLE client_social_accounts (
  id SERIAL PRIMARY KEY,
  client_id INTEGER REFERENCES clients(id),
  platform VARCHAR(50) NOT NULL, -- 'instagram', 'tiktok', 'linkedin'
  username VARCHAR(255) NOT NULL,
  account_url VARCHAR(500) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(client_id, platform, username)
);

-- Historical data snapshots
CREATE TABLE social_metrics_snapshots (
  id SERIAL PRIMARY KEY,
  account_id INTEGER REFERENCES client_social_accounts(id),
  snapshot_date DATE NOT NULL,
  followers INTEGER,
  following INTEGER,
  posts_count INTEGER,
  total_likes INTEGER,
  total_comments INTEGER,
  avg_engagement_rate DECIMAL(5, 2),
  reach INTEGER,
  impressions INTEGER,
  profile_views INTEGER,
  raw_data JSONB, -- Store full response for reference
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(account_id, snapshot_date)
);

-- Top performing posts
CREATE TABLE top_posts (
  id SERIAL PRIMARY KEY,
  account_id INTEGER REFERENCES client_social_accounts(id),
  platform VARCHAR(50),
  post_url VARCHAR(500),
  post_type VARCHAR(50), -- 'photo', 'video', 'carousel', 'reel'
  caption TEXT,
  thumbnail_url VARCHAR(500),
  like_count INTEGER,
  comment_count INTEGER,
  view_count INTEGER,
  engagement_rate DECIMAL(5, 2),
  posted_at TIMESTAMP,
  captured_at TIMESTAMP DEFAULT NOW()
);

-- Generated reports
CREATE TABLE generated_reports (
  id SERIAL PRIMARY KEY,
  client_id INTEGER REFERENCES clients(id),
  report_period VARCHAR(50), -- '2025-01' for January 2025
  pdf_url VARCHAR(500),
  email_sent BOOLEAN DEFAULT FALSE,
  email_sent_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_snapshots_account ON social_metrics_snapshots(account_id);
CREATE INDEX idx_snapshots_date ON social_metrics_snapshots(snapshot_date);
CREATE INDEX idx_top_posts_account ON top_posts(account_id);
CREATE INDEX idx_reports_client ON generated_reports(client_id);

System Flow

  1. Daily Cron Job → Collect metrics for all accounts → Store in database
  2. Monthly Cron Job → Generate reports for all clients → Email PDFs
  3. Dashboard → View current metrics, trends, compare clients
  4. Manual Trigger → Generate report on-demand for specific client

Step 1: Auto-Collect Client Data Daily

Let's build a system that automatically collects data from all your client accounts every day.

JavaScript Implementation

const axios = require('axios');
const { Pool } = require('pg');
const cron = require('node-cron');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

const SOCIAVAULT_API_KEY = process.env.SOCIAVAULT_API_KEY;
const BASE_URL = 'https://api.sociavault.com';

// Collect Instagram metrics
async function collectInstagramMetrics(account) {
  const response = await axios.get(`${BASE_URL}/api/scrape/instagram/profile`, {
    params: { username: account.username },
    headers: { 'X-API-Key': SOCIAVAULT_API_KEY }
  });

  const data = response.data;

  return {
    followers: data.followerCount,
    following: data.followingCount,
    posts_count: data.postsCount,
    total_likes: data.totalLikes,
    avg_engagement_rate: data.engagementRate,
    profile_views: data.profileViews,
    raw_data: data
  };
}

// Collect TikTok metrics
async function collectTikTokMetrics(account) {
  const response = await axios.get(`${BASE_URL}/api/scrape/tiktok/profile`, {
    params: { username: account.username },
    headers: { 'X-API-Key': SOCIAVAULT_API_KEY }
  });

  const data = response.data;

  return {
    followers: data.followerCount,
    following: data.followingCount,
    posts_count: data.videoCount,
    total_likes: data.totalLikes,
    avg_engagement_rate: data.engagementRate,
    reach: data.viewCount,
    raw_data: data
  };
}

// Store metrics snapshot
async function saveSnapshot(accountId, metrics) {
  const today = new Date().toISOString().split('T')[0];

  await pool.query(`
    INSERT INTO social_metrics_snapshots (
      account_id,
      snapshot_date,
      followers,
      following,
      posts_count,
      total_likes,
      avg_engagement_rate,
      reach,
      profile_views,
      raw_data
    )
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
    ON CONFLICT (account_id, snapshot_date)
    DO UPDATE SET
      followers = EXCLUDED.followers,
      following = EXCLUDED.following,
      posts_count = EXCLUDED.posts_count,
      total_likes = EXCLUDED.total_likes,
      avg_engagement_rate = EXCLUDED.avg_engagement_rate,
      reach = EXCLUDED.reach,
      profile_views = EXCLUDED.profile_views,
      raw_data = EXCLUDED.raw_data,
      created_at = NOW()
  `, [
    accountId,
    today,
    metrics.followers,
    metrics.following,
    metrics.posts_count,
    metrics.total_likes,
    metrics.avg_engagement_rate,
    metrics.reach || null,
    metrics.profile_views || null,
    metrics.raw_data
  ]);
}

// Main collection function
async function collectAllClientMetrics() {
  console.log('Starting daily metrics collection...');

  try {
    // Get all client social accounts
    const { rows: accounts } = await pool.query(`
      SELECT
        csa.id,
        csa.platform,
        csa.username,
        c.name AS client_name
      FROM client_social_accounts csa
      JOIN clients c ON c.id = csa.client_id
      ORDER BY c.name, csa.platform
    `);

    console.log(`Found ${accounts.length} accounts to collect`);

    for (const account of accounts) {
      try {
        console.log(`Collecting ${account.platform} data for ${account.client_name} (@${account.username})...`);

        let metrics;

        if (account.platform === 'instagram') {
          metrics = await collectInstagramMetrics(account);
        } else if (account.platform === 'tiktok') {
          metrics = await collectTikTokMetrics(account);
        } else if (account.platform === 'linkedin') {
          // Add LinkedIn collection
          continue; // Skip for now
        } else {
          console.log(`Unknown platform: ${account.platform}`);
          continue;
        }

        await saveSnapshot(account.id, metrics);

        console.log(`✅ Saved metrics for ${account.client_name} - ${account.platform}`);

        // Rate limiting
        await new Promise(resolve => setTimeout(resolve, 2000));

      } catch (error) {
        console.error(`❌ Error collecting ${account.client_name} ${account.platform}:`, error.message);
      }
    }

    console.log('Daily collection complete!');

  } catch (error) {
    console.error('Collection failed:', error);
  }
}

// Schedule daily collection at 2 AM
cron.schedule('0 2 * * *', collectAllClientMetrics);

// Run immediately on start (for testing)
if (require.main === module) {
  collectAllClientMetrics();
}

Python Implementation

import os
import requests
import psycopg2
from datetime import date
from apscheduler.schedulers.blocking import BlockingScheduler

DATABASE_URL = os.getenv('DATABASE_URL')
SOCIAVAULT_API_KEY = os.getenv('SOCIAVAULT_API_KEY')
BASE_URL = 'https://api.sociavault.com'

def get_db_connection():
    """Get database connection"""
    return psycopg2.connect(DATABASE_URL)

def collect_instagram_metrics(username):
    """Collect Instagram metrics"""
    response = requests.get(
        f'{BASE_URL}/api/scrape/instagram/profile',
        params={'username': username},
        headers={'X-API-Key': SOCIAVAULT_API_KEY}
    )
    response.raise_for_status()
    data = response.json()
    
    return {
        'followers': data['followerCount'],
        'following': data['followingCount'],
        'posts_count': data['postsCount'],
        'total_likes': data.get('totalLikes', 0),
        'avg_engagement_rate': data.get('engagementRate', 0),
        'profile_views': data.get('profileViews', 0),
        'raw_data': data
    }

def collect_tiktok_metrics(username):
    """Collect TikTok metrics"""
    response = requests.get(
        f'{BASE_URL}/api/scrape/tiktok/profile',
        params={'username': username},
        headers={'X-API-Key': SOCIAVAULT_API_KEY}
    )
    response.raise_for_status()
    data = response.json()
    
    return {
        'followers': data['followerCount'],
        'following': data['followingCount'],
        'posts_count': data['videoCount'],
        'total_likes': data['totalLikes'],
        'avg_engagement_rate': data.get('engagementRate', 0),
        'reach': data.get('viewCount', 0),
        'raw_data': data
    }

def save_snapshot(conn, account_id, metrics):
    """Save metrics snapshot to database"""
    today = date.today().isoformat()
    
    with conn.cursor() as cur:
        cur.execute("""
            INSERT INTO social_metrics_snapshots (
                account_id, snapshot_date, followers, following,
                posts_count, total_likes, avg_engagement_rate,
                reach, profile_views, raw_data
            )
            VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
            ON CONFLICT (account_id, snapshot_date)
            DO UPDATE SET
                followers = EXCLUDED.followers,
                following = EXCLUDED.following,
                posts_count = EXCLUDED.posts_count,
                total_likes = EXCLUDED.total_likes,
                avg_engagement_rate = EXCLUDED.avg_engagement_rate,
                reach = EXCLUDED.reach,
                profile_views = EXCLUDED.profile_views,
                raw_data = EXCLUDED.raw_data,
                created_at = NOW()
        """, [
            account_id,
            today,
            metrics['followers'],
            metrics['following'],
            metrics['posts_count'],
            metrics['total_likes'],
            metrics['avg_engagement_rate'],
            metrics.get('reach'),
            metrics.get('profile_views'),
            psycopg2.extras.Json(metrics['raw_data'])
        ])
    
    conn.commit()

def collect_all_metrics():
    """Main collection function"""
    print('Starting daily metrics collection...')
    
    conn = get_db_connection()
    
    try:
        with conn.cursor() as cur:
            cur.execute("""
                SELECT
                    csa.id,
                    csa.platform,
                    csa.username,
                    c.name AS client_name
                FROM client_social_accounts csa
                JOIN clients c ON c.id = csa.client_id
                ORDER BY c.name, csa.platform
            """)
            
            accounts = cur.fetchall()
        
        print(f'Found {len(accounts)} accounts to collect')
        
        for account_id, platform, username, client_name in accounts:
            try:
                print(f'Collecting {platform} data for {client_name} (@{username})...')
                
                if platform == 'instagram':
                    metrics = collect_instagram_metrics(username)
                elif platform == 'tiktok':
                    metrics = collect_tiktok_metrics(username)
                else:
                    print(f'Unknown platform: {platform}')
                    continue
                
                save_snapshot(conn, account_id, metrics)
                
                print(f'✅ Saved metrics for {client_name} - {platform}')
                
                # Rate limiting
                time.sleep(2)
                
            except Exception as e:
                print(f'❌ Error collecting {client_name} {platform}: {e}')
        
        print('Daily collection complete!')
        
    finally:
        conn.close()

# Schedule daily collection at 2 AM
if __name__ == '__main__':
    scheduler = BlockingScheduler()
    scheduler.add_job(collect_all_metrics, 'cron', hour=2)
    
    # Run immediately for testing
    collect_all_metrics()
    
    # Start scheduler
    print('Scheduler started. Press Ctrl+C to exit.')
    scheduler.start()

What this does:

  • Runs every day at 2 AM automatically
  • Collects metrics from ALL client accounts (Instagram, TikTok)
  • Stores historical snapshots in database
  • Handles errors gracefully (one failure doesn't stop others)
  • Rate limits to avoid API issues

Cost: ~0.002 credits per account per day (Instagram + TikTok) For 15 clients with 30 accounts: $0.06/day = $1.80/month

Compare that to the $2,775 in billable time you're saving.

Step 2: Collect Top Performing Posts

Clients love seeing their best content. Let's automatically capture it.

async function collectTopPosts(accountId, platform, username) {
  let posts = [];

  if (platform === 'instagram') {
    const response = await axios.get(`${BASE_URL}/api/scrape/instagram/posts`, {
      params: {
        username: username,
        limit: 20 // Last 20 posts
      },
      headers: { 'X-API-Key': SOCIAVAULT_API_KEY }
    });

    posts = response.data;

  } else if (platform === 'tiktok') {
    const response = await axios.get(`${BASE_URL}/api/scrape/tiktok/videos`, {
      params: {
        username: username,
        limit: 20
      },
      headers: { 'X-API-Key': SOCIAVAULT_API_KEY }
    });

    posts = response.data;
  }

  // Sort by engagement
  posts.sort((a, b) => {
    const engA = (a.likeCount || 0) + (a.commentCount || 0) * 2 + (a.viewCount || 0) * 0.01;
    const engB = (b.likeCount || 0) + (b.commentCount || 0) * 2 + (b.viewCount || 0) * 0.01;
    return engB - engA;
  });

  // Take top 5
  const topPosts = posts.slice(0, 5);

  // Save to database
  for (const post of topPosts) {
    const engagement_rate = calculateEngagementRate(post);

    await pool.query(`
      INSERT INTO top_posts (
        account_id,
        platform,
        post_url,
        post_type,
        caption,
        thumbnail_url,
        like_count,
        comment_count,
        view_count,
        engagement_rate,
        posted_at
      )
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
      ON CONFLICT DO NOTHING
    `, [
      accountId,
      platform,
      post.url,
      post.type || 'post',
      post.caption?.substring(0, 500),
      post.thumbnailUrl,
      post.likeCount,
      post.commentCount,
      post.viewCount || null,
      engagement_rate,
      post.timestamp
    ]);
  }

  console.log(`✅ Saved top ${topPosts.length} posts for account ${accountId}`);
}

function calculateEngagementRate(post) {
  const followers = 10000; // You'd get this from profile data
  const engagements = (post.likeCount || 0) + (post.commentCount || 0);
  return ((engagements / followers) * 100).toFixed(2);
}

Step 3: Generate Branded PDF Reports

Now the fun part - generating beautiful, branded PDF reports.

Install Dependencies

npm install pdfkit

PDF Generation Code

const PDFDocument = require('pdfkit');
const fs = require('fs');

async function generateClientReport(clientId, reportMonth) {
  // Get client info
  const { rows: [client] } = await pool.query(`
    SELECT * FROM clients WHERE id = $1
  `, [clientId]);

  // Get accounts
  const { rows: accounts } = await pool.query(`
    SELECT * FROM client_social_accounts WHERE client_id = $1
  `, [clientId]);

  // Get metrics for this month
  const startDate = `${reportMonth}-01`;
  const endDate = `${reportMonth}-31`;

  const metrics = {};

  for (const account of accounts) {
    const { rows } = await pool.query(`
      SELECT *
      FROM social_metrics_snapshots
      WHERE account_id = $1
        AND snapshot_date BETWEEN $2 AND $3
      ORDER BY snapshot_date
    `, [account.id, startDate, endDate]);

    metrics[account.platform] = {
      current: rows[rows.length - 1],
      previous: rows[0],
      growth: calculateGrowth(rows[0], rows[rows.length - 1])
    };
  }

  // Get top posts
  const { rows: topPosts } = await pool.query(`
    SELECT *
    FROM top_posts
    WHERE account_id = ANY($1)
      AND posted_at >= $2
    ORDER BY engagement_rate DESC
    LIMIT 5
  `, [accounts.map(a => a.id), startDate]);

  // Create PDF
  const doc = new PDFDocument({ size: 'A4', margin: 50 });
  const filename = `reports/${client.name.replace(/\s+/g, '_')}_${reportMonth}.pdf`;
  doc.pipe(fs.createWriteStream(filename));

  // Header
  doc.fontSize(24)
     .fillColor(client.primary_color)
     .text(`${client.name} - Social Media Report`, { align: 'center' });

  doc.fontSize(14)
     .fillColor('#666')
     .text(formatMonthYear(reportMonth), { align: 'center' });

  doc.moveDown(2);

  // Instagram Section
  if (metrics.instagram) {
    doc.fontSize(18)
       .fillColor('#000')
       .text('Instagram Performance');

    doc.moveDown();

    const ig = metrics.instagram;

    // Followers
    doc.fontSize(12)
       .text(`Followers: ${ig.current.followers.toLocaleString()}`, { continued: true })
       .fillColor(ig.growth.followers >= 0 ? '#22c55e' : '#ef4444')
       .text(` ${ig.growth.followers >= 0 ? '+' : ''}${ig.growth.followers}`, { align: 'right' });

    doc.fillColor('#000');

    // Engagement Rate
    doc.text(`Engagement Rate: ${ig.current.avg_engagement_rate}%`, { continued: true })
       .fillColor(ig.growth.engagement >= 0 ? '#22c55e' : '#ef4444')
       .text(` ${ig.growth.engagement >= 0 ? '+' : ''}${ig.growth.engagement.toFixed(2)}%`, { align: 'right' });

    doc.fillColor('#000');

    // Posts
    doc.text(`Posts: ${ig.current.posts_count}`, { continued: true })
       .text(` (+${ig.growth.posts_count} this month)`, { align: 'right' });

    doc.moveDown(2);
  }

  // TikTok Section
  if (metrics.tiktok) {
    doc.fontSize(18)
       .fillColor('#000')
       .text('TikTok Performance');

    doc.moveDown();

    const tt = metrics.tiktok;

    doc.fontSize(12)
       .text(`Followers: ${tt.current.followers.toLocaleString()}`, { continued: true })
       .fillColor(tt.growth.followers >= 0 ? '#22c55e' : '#ef4444')
       .text(` ${tt.growth.followers >= 0 ? '+' : ''}${tt.growth.followers}`, { align: 'right' });

    doc.fillColor('#000');

    doc.text(`Total Views: ${tt.current.reach.toLocaleString()}`, { continued: true })
       .fillColor(tt.growth.reach >= 0 ? '#22c55e' : '#ef4444')
       .text(` ${tt.growth.reach >= 0 ? '+' : ''}${tt.growth.reach.toLocaleString()}`, { align: 'right' });

    doc.fillColor('#000');

    doc.text(`Videos: ${tt.current.posts_count}`, { continued: true })
       .text(` (+${tt.growth.posts_count} this month)`, { align: 'right' });

    doc.moveDown(2);
  }

  // Top Posts Section
  doc.addPage();

  doc.fontSize(18)
     .fillColor('#000')
     .text('Top Performing Posts');

  doc.moveDown();

  topPosts.forEach((post, i) => {
    doc.fontSize(12)
       .text(`${i + 1}. ${post.platform.toUpperCase()} Post`);

    doc.fontSize(10)
       .fillColor('#666')
       .text(post.caption?.substring(0, 100) || 'No caption');

    doc.fontSize(10)
       .fillColor('#000')
       .text(`❤️ ${post.like_count.toLocaleString()}   💬 ${post.comment_count.toLocaleString()}   📊 ${post.engagement_rate}% engagement`);

    doc.moveDown();
  });

  // Recommendations Section
  doc.addPage();

  doc.fontSize(18)
     .fillColor('#000')
     .text('Recommendations');

  doc.moveDown();

  // Add your insights here
  doc.fontSize(12)
     .fillColor('#000')
     .text('• Continue posting Reels - they consistently get higher engagement');

  doc.text('• Increase TikTok frequency to 3-4x per week for better algorithm performance');

  doc.text('• Test carousel posts on Instagram - similar brands seeing 2x engagement');

  // Footer
  doc.fontSize(10)
     .fillColor('#666')
     .text(`Generated by YourAgency.com on ${new Date().toLocaleDateString()}`, {
       align: 'center'
     });

  doc.end();

  console.log(`✅ Generated report: ${filename}`);

  return filename;
}

function calculateGrowth(previous, current) {
  return {
    followers: current.followers - previous.followers,
    posts_count: current.posts_count - previous.posts_count,
    engagement: current.avg_engagement_rate - previous.avg_engagement_rate,
    reach: (current.reach || 0) - (previous.reach || 0)
  };
}

function formatMonthYear(reportMonth) {
  const [year, month] = reportMonth.split('-');
  const date = new Date(year, parseInt(month) - 1);
  return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
}

Step 4: Email Reports to Clients

Automatically email the PDF report to your client.

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  secure: false,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS
  }
});

async function emailReport(clientId, pdfPath, reportMonth) {
  const { rows: [client] } = await pool.query(`
    SELECT * FROM clients WHERE id = $1
  `, [clientId]);

  const monthName = formatMonthYear(reportMonth);

  await transporter.sendMail({
    from: '"Your Agency" <reports@youragency.com>',
    to: client.email,
    subject: `Your ${monthName} Social Media Report`,
    html: `
      <h2>Hi ${client.name}!</h2>
      <p>Your monthly social media report is ready.</p>
      <p><strong>Key Highlights:</strong></p>
      <ul>
        <li>Instagram followers grew by X%</li>
        <li>TikTok reached Y views this month</li>
        <li>Top post: [link]</li>
      </ul>
      <p>The full report is attached. Let's schedule a call to discuss next month's strategy!</p>
      <p>Best regards,<br>Your Agency Team</p>
    `,
    attachments: [
      {
        filename: `${client.name}_${reportMonth}_Report.pdf`,
        path: pdfPath
      }
    ]
  });

  // Mark as sent
  await pool.query(`
    UPDATE generated_reports
    SET email_sent = TRUE, email_sent_at = NOW()
    WHERE client_id = $1 AND report_period = $2
  `, [clientId, reportMonth]);

  console.log(`✅ Emailed report to ${client.email}`);
}

Step 5: Monthly Automation

Put it all together with a monthly cron job.

// Run on the 1st of every month at 6 AM
cron.schedule('0 6 1 * *', async () => {
  console.log('Starting monthly report generation...');

  // Get last month
  const lastMonth = getLastMonth(); // '2025-01'

  // Get all clients
  const { rows: clients } = await pool.query('SELECT * FROM clients');

  for (const client of clients) {
    try {
      console.log(`Generating report for ${client.name}...`);

      // Generate PDF
      const pdfPath = await generateClientReport(client.id, lastMonth);

      // Save to database
      await pool.query(`
        INSERT INTO generated_reports (client_id, report_period, pdf_url)
        VALUES ($1, $2, $3)
      `, [client.id, lastMonth, pdfPath]);

      // Email the report
      await emailReport(client.id, pdfPath, lastMonth);

      console.log(`✅ Completed report for ${client.name}`);

      // Rate limiting
      await new Promise(resolve => setTimeout(resolve, 5000));

    } catch (error) {
      console.error(`❌ Error generating report for ${client.name}:`, error);
    }
  }

  console.log('Monthly reporting complete!');
});

function getLastMonth() {
  const now = new Date();
  now.setMonth(now.getMonth() - 1);
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  return `${year}-${month}`;
}

Complete Reporting Dashboard

Build a simple dashboard where you can:

  • View current metrics for all clients
  • Generate reports on-demand
  • See historical trends
  • Compare clients
const express = require('express');
const app = express();

// Dashboard: List all clients
app.get('/dashboard', async (req, res) => {
  const { rows: clients } = await pool.query(`
    SELECT
      c.id,
      c.name,
      COUNT(DISTINCT csa.id) AS accounts_count,
      MAX(sms.snapshot_date) AS last_updated
    FROM clients c
    LEFT JOIN client_social_accounts csa ON csa.client_id = c.id
    LEFT JOIN social_metrics_snapshots sms ON sms.account_id = csa.id
    GROUP BY c.id, c.name
    ORDER BY c.name
  `);

  res.json({ clients });
});

// Client detail: Current metrics
app.get('/clients/:id/metrics', async (req, res) => {
  const clientId = req.params.id;

  const { rows: accounts } = await pool.query(`
    SELECT
      csa.*,
      sms.followers,
      sms.following,
      sms.posts_count,
      sms.avg_engagement_rate,
      sms.snapshot_date
    FROM client_social_accounts csa
    LEFT JOIN LATERAL (
      SELECT *
      FROM social_metrics_snapshots
      WHERE account_id = csa.id
      ORDER BY snapshot_date DESC
      LIMIT 1
    ) sms ON true
    WHERE csa.client_id = $1
  `, [clientId]);

  res.json({ accounts });
});

// Generate report on-demand
app.post('/clients/:id/generate-report', async (req, res) => {
  const clientId = req.params.id;
  const reportMonth = req.body.month || getLastMonth();

  try {
    const pdfPath = await generateClientReport(clientId, reportMonth);

    res.json({
      success: true,
      pdfUrl: pdfPath
    });

  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});

app.listen(3000, () => {
  console.log('Dashboard running on http://localhost:3000');
});

Metrics That Matter to Clients

Don't overwhelm clients with data. Focus on what matters:

Growth Metrics

  • Follower growth (absolute number + percentage)
  • Engagement rate (likes + comments / followers)
  • Reach (how many people saw your content)

Content Performance

  • Top 5 posts (sorted by engagement)
  • Best posting times (when content performs best)
  • Content types (Reels vs Posts vs Carousels)

Trend Analysis

  • Month-over-month growth (visual chart)
  • Follower velocity (growing faster or slower?)
  • Engagement trends (going up or down?)

Recommendations

  • What's working (double down on this)
  • What's not (test something new)
  • Next month's strategy (specific action items)

Rule of thumb: If you can't explain why a metric matters in one sentence, don't include it.

Cost Analysis: Manual vs Automated

Manual Reporting (Current State)

Time investment:

  • 15 clients × 75 minutes = 18.75 hours/month

Cost at $150/hour:

  • $2,812.50/month in lost billable time

Annual cost:

  • $33,750/year

Frustration level:

  • ∞ (infinite Sunday night panic sessions)

Automated Reporting

Setup time:

  • 8-12 hours (one-time)

Monthly time:

  • Data collection: 0 hours (automated)
  • Report generation: 0 hours (automated)
  • Adding insights: 15 clients × 5 minutes = 75 minutes

Monthly cost:

  • SociaVault API: ~$5/month (15 clients × 2 accounts × $0.001 × 30 days)
  • Email service: $10/month (SendGrid, Mailgun)
  • Server/hosting: $20/month
  • Total: ~$35/month

Time saved:

  • 17.5 hours/month

Billable hours reclaimed:

  • $2,625/month

Annual savings:

  • $31,500/year

ROI:

  • Setup: 8-12 hours
  • Payback: After first month
  • Return: 9,000% (one-time cost → ongoing savings)

Frustration level:

  • Zero

Real Agency Example

Agency: Digital Boost Marketing (fictional but realistic)

Clients: 18 social media management clients

Before automation:

  • 22.5 hours/month on reporting
  • $3,375/month in lost billable time
  • Sunday nights = report hell
  • Inconsistent report quality
  • Frequently late reports

After automation:

  • 90 minutes/month (adding insights only)
  • Reports generated automatically on the 1st
  • Consistent, branded PDFs every time
  • Clients receive reports before 9 AM
  • 21 hours/month freed up for client work

Impact:

  • Took on 4 more clients (capacity freed up)
  • Increased client retention (timely, professional reports)
  • Upsold to 6 clients (had time to analyze and recommend upgrades)
  • Additional revenue: $8,000/month
  • ROI: $11,375/month (savings + new revenue)

Common Mistakes

1. Too Many Metrics

Bad report:

  • 50 metrics
  • 20 charts
  • 15 pages
  • Takes 30 minutes to read

Good report:

  • 6-8 key metrics
  • 3-4 charts
  • 3-5 pages
  • Takes 5 minutes to read

Remember: Clients are busy. They want insights, not data dumps.

2. No Context

Bad: "You have 10,453 followers."

Good: "You gained 423 new followers this month (+4.2%), which is 2x faster than last month. Your Reels are driving most of the growth."

Always provide:

  • Growth numbers (absolute + percentage)
  • Comparison to previous period
  • Why it happened
  • What to do next

3. Missing Recommendations

Reports without recommendations are useless.

Every report should end with:

  • Keep doing: [What's working]
  • Try next: [New strategy]
  • Stop doing: [What's not working]

4. Not Collecting Daily

Mistake: Only collecting data when you generate reports (monthly).

Problem: No historical data. Can't show trends. Can't track day-to-day changes.

Solution: Collect metrics every day. Generate reports monthly.

Advanced Features to Add

1. Competitor Tracking

Track your client's competitors automatically:

const competitors = await pool.query(`
  SELECT * FROM competitor_accounts
  WHERE client_id = $1
`, [clientId]);

for (const competitor of competitors.rows) {
  const data = await getInstagramProfile(competitor.username);
  
  await pool.query(`
    INSERT INTO competitor_snapshots (...)
    VALUES (...)
  `);
}

2. Automated Insights

Use simple heuristics to generate insights:

function generateInsights(current, previous) {
  const insights = [];

  // Follower growth accelerating
  if (current.follower_growth > previous.follower_growth * 1.2) {
    insights.push('🚀 Follower growth is accelerating! Keep up your current content strategy.');
  }

  // Engagement declining
  if (current.engagement_rate < previous.engagement_rate * 0.9) {
    insights.push('⚠️ Engagement rate is down. Consider posting more Reels or asking more questions in captions.');
  }

  // Low posting frequency
  if (current.posts_this_month < 10) {
    insights.push('📅 Only posted ' + current.posts_this_month + ' times this month. Aim for 3-4x per week for better algorithm performance.');
  }

  return insights;
}

3. White-Label Dashboard

Give clients login access to view their metrics anytime:

// Client login
app.post('/client-login', async (req, res) => {
  const { email, password } = req.body;
  
  // Authenticate client
  const client = await authenticateClient(email, password);
  
  if (client) {
    // Generate JWT token
    const token = jwt.sign({ clientId: client.id }, SECRET_KEY);
    res.json({ token });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// Client dashboard (authenticated)
app.get('/my-metrics', authenticateMiddleware, async (req, res) => {
  const clientId = req.user.clientId;
  
  // Get current metrics
  const metrics = await getClientMetrics(clientId);
  
  res.json(metrics);
});

4. Slack/Discord Notifications

Send weekly updates to your team or client's Slack:

const axios = require('axios');

async function sendSlackUpdate(client, metrics) {
  await axios.post(process.env.SLACK_WEBHOOK_URL, {
    text: `📊 Weekly Update: ${client.name}`,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*${client.name}* - This Week's Performance:`
        }
      },
      {
        type: 'section',
        fields: [
          {
            type: 'mrkdwn',
            text: `*Instagram*\n+${metrics.instagram.new_followers} followers`
          },
          {
            type: 'mrkdwn',
            text: `*TikTok*\n${metrics.tiktok.total_views.toLocaleString()} views`
          }
        ]
      }
    ]
  });
}

Conclusion

Stop spending 20 hours a month copy-pasting numbers into Excel.

You're an agency. Your value is strategy, creativity, and growth. Not data entry.

This guide gave you:

  • Automated data collection (runs daily, no manual exports)
  • Branded PDF reports (professional, consistent, on-time)
  • Email automation (reports delivered automatically)
  • Historical tracking (trends over time, no more "I forgot to screenshot")
  • Production-ready code (JavaScript and Python, copy-paste-deploy)

The result:

  • 15-20 hours/month saved
  • $2,500-3,000 in billable time reclaimed
  • Happier clients (timely, consistent reports)
  • Happier team (no more Sunday night marathons)

Investment:

  • 8-12 hours setup (one-time)
  • $35/month ongoing costs
  • 9,000% ROI

Every week you wait, you're losing $650 in billable time.

Start automating today. Your future self will thank you.

Get started: sociavault.com - Get your API key and automate your first client report this weekend.

No more copy-pasting. No more Sunday nights. No more report hell.

Just growth.

Found this helpful?

Share it with others who might benefit

Ready to Try SociaVault?

Start extracting social media data with our powerful API