Building TooSimple Part 3: Multi-Platform Social Imports & Auto-Approval Engine

March 4, 2026 (4mo ago)

In Part 1, I covered the full story behind toosimple.io. In Part 2, I went deep on the widget embed system and video processing pipeline. This post covers the two features that had the biggest impact on user activation: social platform imports and auto-approval.

Why Social Imports Matter

The biggest friction point for any testimonial platform is the cold start. A new user signs up, sees an empty dashboard, and has to wait for customers to submit testimonials before they can display anything. That's a terrible first experience.

Most founders already have testimonials scattered across social media - tweets praising their product, LinkedIn recommendations, Reddit comments, YouTube reviews. The import system lets them go from empty dashboard to 10+ testimonials ready to display in under 5 minutes.

The Adapter Pattern

I built the import system around a generic adapter pattern. Every platform shares the same interface, but each has its own URL parsing, data fetching, and content extraction logic:

type ImportSource =
  | 'twitter'    | 'linkedin'   | 'reddit'
  | 'youtube'    | 'facebook'   | 'tiktok'
  | 'vimeo'      | 'tumblr'     | 'pinterest'
  | 'google'     | 'producthunt' | 'trustpilot'
  | 'capterra'   | 'yelp'       | 'appstore'
  | 'instagram';
 
interface SocialAdapter {
  extractId(url: string): string | null;
  fetch(url: string): Promise<ImportedTestimonial>;
}

Each adapter follows the same contract: extract an ID from the URL, fetch the content via oEmbed or API, and return a normalized testimonial object:

interface ImportedTestimonial {
  source: ImportSource;
  externalId: string;
  externalUrl: string;
  authorName: string;
  authorAvatar?: string;
  authorHandle?: string;
  authorVerified: boolean;
  content: string;
  rating?: number;
  platformMetadata: Record<string, unknown>;
}

oEmbed: The Universal Fetching Strategy

Most platforms support oEmbed - a standard format for embedding content. Instead of building custom scrapers for each platform, I hit their oEmbed endpoints:

// Twitter/X oEmbed
const response = await fetch(
  `https://publish.x.com/oembed?url=${encodeURIComponent(url)}&omit_script=true`
);
const data = await response.json();

The omit_script=true parameter is important - without it, the oEmbed response includes Twitter's embed JavaScript, which we don't need since we're extracting the text content.

Platform-Specific Adapters

Each platform has its quirks. Here are the interesting ones:

Twitter/X - Multiple URL formats to handle:

const twitterAdapter: SocialAdapter = {
  extractId(url: string): string | null {
    // Handles: twitter.com/{user}/status/{id}
    //          x.com/{user}/status/{id}
    //          x.com/i/status/{id}
    const match = url.match(/(?:twitter\.com|x\.com)\/(?:\w+\/)?status\/(\d+)/);
    return match?.[1] || null;
  },
 
  async fetch(url: string): Promise<ImportedTestimonial> {
    const data = await fetchOEmbed('https://publish.x.com/oembed', url);
    // Content extraction: parse HTML to get tweet text
    const content = extractTextFromHtml(data.html);
    // Normalize URL to canonical format
    const normalizedUrl = `https://x.com/i/status/${this.extractId(url)}`;
 
    return {
      source: 'twitter',
      externalId: this.extractId(url)!,
      externalUrl: normalizedUrl,
      authorName: data.author_name,
      authorHandle: `@${data.author_url.split('/').pop()}`,
      content,
    };
  }
};

LinkedIn - Uses linkedom for HTML parsing since oEmbed returns content inside an iframe:

const linkedinAdapter: SocialAdapter = {
  extractId(url: string): string | null {
    // Handles: linkedin.com/posts/{username}_activity-{id}
    //          urn:li:activity:{id}
    //          linkedin.com/feed/update/urn:li:share:{id}
    const activityMatch = url.match(/activity-(\d+)/);
    if (activityMatch) return activityMatch[1];
    const urnMatch = url.match(/urn:li:(?:activity|share):(\d+)/);
    return urnMatch?.[1] || null;
  },
 
  async fetch(url: string): Promise<ImportedTestimonial> {
    const data = await fetchOEmbed('https://www.linkedin.com/oembed', url);
    // LinkedIn returns content in iframe title + HTML fallback
    const { parseHTML } = await import('linkedom');
    const { document } = parseHTML(data.html);
    const content = document.querySelector('iframe')?.title
      || extractTextFromHtml(data.html);
 
    return { source: 'linkedin', content, ... };
  }
};

TikTok - Intentionally rejects short URLs to prevent duplicate imports:

const tiktokAdapter: SocialAdapter = {
  extractId(url: string): string | null {
    // Only accept canonical URLs: tiktok.com/@{user}/video/{id}
    // Reject vm.tiktok.com short URLs (they redirect, causing duplicate detection issues)
    if (url.includes('vm.tiktok.com')) return null;
    const match = url.match(/tiktok\.com\/@[\w.]+\/video\/(\d+)/);
    return match?.[1] || null;
  }
};

Short TikTok URLs (vm.tiktok.com) redirect to the canonical URL. If I allowed both, a user could import the same video twice using different URL formats. Rejecting short URLs at the extraction step prevents this.

Reddit - Stores subreddit as extra metadata:

const redditAdapter: SocialAdapter = {
  extractId(url: string): string | null {
    const match = url.match(/\/r\/\w+\/comments\/(\w+)/);
    return match?.[1] || null;
  },
 
  async fetch(url: string): Promise<ImportedTestimonial> {
    const data = await fetchOEmbed('https://www.reddit.com/oembed', url);
    const subreddit = url.match(/\/r\/(\w+)\//)?.[1];
    return {
      source: 'reddit',
      content: data.title || extractTextFromHtml(data.html),
      platformMetadata: { subreddit },
      ...
    };
  }
};

YouTube - Handles 3 URL formats and extracts channel metadata:

const youtubeAdapter: SocialAdapter = {
  extractId(url: string): string | null {
    // youtube.com/watch?v={id}
    // youtu.be/{id}
    // youtube.com/embed/{id}
    const patterns = [
      /[?&]v=([a-zA-Z0-9_-]{11})/,
      /youtu\.be\/([a-zA-Z0-9_-]{11})/,
      /embed\/([a-zA-Z0-9_-]{11})/,
    ];
    for (const p of patterns) {
      const m = url.match(p);
      if (m) return m[1];
    }
    return null;
  },
 
  async fetch(url: string): Promise<ImportedTestimonial> {
    const data = await fetchOEmbed('https://www.youtube.com/oembed', url);
    return {
      source: 'youtube',
      content: data.title,
      authorName: data.author_name,
      thumbnailUrl: data.thumbnail_url,
      platformMetadata: {
        channelUrl: data.author_url,
        channelName: data.author_name,
      },
      ...
    };
  }
};

Instagram - The one platform that doesn't work with oEmbed alone. It requires Facebook Graph API authentication, which I handle separately:

// Instagram requires authenticated API access
async function fetchInstagramData(url: string): Promise<ImportedTestimonial> {
  throw new Error("Instagram import requires authentication");
  // Handled in the dashboard with a separate OAuth flow
}

URL Safety & SSRF Protection

Every oEmbed URL is validated against an allowlist before fetching:

function validateOEmbedUrl(url: string): boolean {
  const parsed = new URL(url);
  // Only HTTPS
  if (parsed.protocol !== 'https:') return false;
  // Must be a known oEmbed provider
  return ALLOWED_OEMBED_HOSTS.has(parsed.hostname);
}

Without this, a malicious user could craft a URL that causes the server to make requests to internal services (SSRF attack). All oEmbed requests also have a 10-second timeout to prevent slow-loris attacks.

The Import Flow

The import process has two phases: preview and import. This gives users a chance to review and edit the extracted content before it goes into their project.

Phase 1: Preview

async function previewImport(url: string): Promise<PreviewResult> {
  // 1. Detect platform from URL
  const adapter = detectAdapter(url);
  if (!adapter) throw new Error('Unsupported platform');
 
  // 2. Extract external ID
  const externalId = adapter.extractId(url);
  if (!externalId) throw new Error('Could not extract post ID');
 
  // 3. Check if already imported (before consuming rate limit)
  const existing = await findTestimonialByExternalId(projectId, adapter.source, externalId);
  if (existing) throw new Error(`This ${adapter.source} post has already been imported`);
 
  // 4. Rate limit: 200/hour preview bucket
  await checkRateLimit('preview', 200, 3600);
 
  // 5. Fetch and return for user review
  const data = await adapter.fetch(url);
  return { ...data, editable: true };
}

The rate limit check happens after the duplicate check. This prevents a user from burning through their rate limit by repeatedly trying to import the same post.

Phase 2: Import

async function importTestimonial(
  projectId: string,
  url: string,
  userEdits: Partial<ImportedTestimonial>,
  autoApprove: boolean
): Promise<Testimonial> {
  const adapter = detectAdapter(url);
  const externalId = adapter.extractId(url)!;
 
  // Re-fetch fresh data (preview data may be stale)
  const freshData = await adapter.fetch(url);
 
  // Merge: user-edited fields (name, content) + API metadata (avatar, verified, metrics)
  const testimonial = await db.insert(testimonials).values({
    projectId,
    source: adapter.source,
    externalId,
    externalUrl: freshData.externalUrl,
    authorName: userEdits.authorName || freshData.authorName,
    authorHandle: freshData.authorHandle,
    authorAvatar: freshData.authorAvatar,
    authorVerified: freshData.authorVerified,
    content: userEdits.content || freshData.content,
    thumbnailUrl: freshData.thumbnailUrl,
    platformMetadata: freshData.platformMetadata,
    rating: userEdits.rating,
    status: autoApprove ? 'approved' : 'pending',
  }).returning();
 
  return testimonial;
}

Why re-fetch on import? Two reasons:

  1. The preview data might be stale (user previewed hours ago)
  2. The user edited the author name and content, but we want fresh avatar, verified status, and platform metrics from the API

Duplicate Prevention

Duplicates are prevented at two levels:

  1. Application-level check before import: query by (projectId, source, externalId)
  2. Database unique constraint: PostgreSQL enforces UNIQUE(projectId, source, externalId), catching any race conditions
try {
  await db.insert(testimonials).values(data);
} catch (err) {
  if (err.code === '23505') {
    throw new Error(`This ${source} post has already been imported to this project`);
  }
  throw err;
}

The database constraint is the safety net. Even if two import requests arrive simultaneously and both pass the application-level check, only one will succeed at the database level.

Auto-Approval Engine

Once imports are flowing (or customers are submitting through collection forms), manual approval becomes a bottleneck. The auto-approval engine lets users define rules for automatic testimonial approval:

interface AutoApprovalRules {
  enabled: boolean;
  minRating: number;        // 1-5, e.g., 4+ stars
  requireText: boolean;     // Must have content, not just rating
  reviewType: 'all' | 'video' | 'text';
  profanityFilter: boolean;
}

The Evaluation Function

The evaluation is a pure function with AND logic - all criteria must pass:

function evaluateTestimonial(
  testimonial: NewTestimonial,
  rules: AutoApprovalRules
): 'approved' | 'pending' {
  if (!rules.enabled) return 'pending';
 
  // Rating check - null/undefined treated as 0
  if ((testimonial.rating || 0) < rules.minRating) return 'pending';
 
  // Content check - protects against star-only submissions
  if (rules.requireText && !testimonial.content?.trim()) return 'pending';
 
  // Media type check
  if (rules.reviewType === 'video' && testimonial.mediaType !== 'video')
    return 'pending';
  if (rules.reviewType === 'text' && testimonial.mediaType !== 'none')
    return 'pending';
 
  // Profanity check on both content and author name
  if (rules.profanityFilter) {
    if (containsProfanity(testimonial.content)) return 'pending';
    if (containsProfanity(testimonial.authorName)) return 'pending';
  }
 
  return 'approved';
}

Design decisions:

  • AND logic, not OR: Every criterion must pass. A 5-star review with profanity still goes to pending. This is conservative by design - false negatives (a good testimonial going to pending) are much less damaging than false positives (a bad testimonial going live).

  • Null ratings treated as 0: If a testimonial has no rating (e.g., imported from Twitter where there's no star system), it gets treated as 0 stars. This means minRating: 1 effectively requires an explicit rating.

  • Profanity checks both fields: Checking only content would miss offensive author names that would display publicly in the widget.

  • Pure function: No side effects, no database calls. This makes it trivially testable and fast to evaluate.

The Settings UI

The dashboard provides a visual configuration panel:

function AutoApprovalSettings({ projectId }: { projectId: string }) {
  return (
    <div className="space-y-4">
      <Toggle label="Enable Auto-Approval" field="enabled" />
      <Select label="Minimum Rating" options={[3, 4, 5]} field="minRating" />
      <Select label="Allowed Types" options={['All', 'Video Only', 'Text Only']} field="reviewType" />
      <Toggle label="Require Text Content" field="requireText" />
      <Toggle label="Profanity Filter" field="profanityFilter" />
    </div>
  );
}

The UI includes change detection - it compares the current form state against the saved baseline and only enables the save button when something has changed. After saving, a brief "Saved" indicator appears for 2 seconds before resetting.

Common Configurations

Most users fall into one of three patterns:

  1. Quality gate: minRating: 5, requireText: true, profanityFilter: true - Only perfect reviews with actual content auto-approve.

  2. Volume mode: minRating: 3, requireText: false, profanityFilter: true - Most testimonials auto-approve, only profanity gets flagged.

  3. Video-first: minRating: 4, reviewType: 'video', requireText: false - Only video testimonials auto-approve (text goes to manual review for curation).

The Impact on Activation

Before social imports, the typical new user journey was:

  1. Sign up
  2. Create a project
  3. Set up a collection form
  4. Send the form to customers
  5. Wait days for testimonials
  6. Approve and embed

After social imports:

  1. Sign up
  2. Create a project
  3. Import 5-10 testimonials from Twitter/LinkedIn
  4. Enable auto-approval
  5. Embed widget immediately

Time-to-value dropped from days to minutes. Users who import at least one social testimonial during onboarding have significantly higher retention - they see the value immediately instead of staring at an empty dashboard.

What I'd Do Differently

Rate limiting per platform: Currently, the rate limit is global (200 previews/hour). If a user spams Twitter imports, they burn through their limit and can't import from LinkedIn. Per-platform buckets would be fairer.

Content validation: The 10-3000 character limit is too rigid. Some tweets are under 10 characters but still make good testimonials ("Love this product!"). I should lower the minimum or make it configurable.

Batch import: Currently, imports are one URL at a time. A batch import feature (paste 10 URLs, import all) would dramatically speed up the initial import process.


Need social proof on your website? toosimple.io lets you collect, import, and display testimonials in minutes. Start free, no credit card required.

Questions or feedback? Find me on X or email [email protected].