Skip to main content
backend21 septembre 202517 min de lecture

Automatiser les publications sur les réseaux sociaux : du calendrier de contenu à l'API

Construire un pipeline d'automatisation des réseaux sociaux — de la planification de contenu à l'intégration API avec les principales plateformes.

automationapisocial-media
Automatiser les publications sur les réseaux sociaux : du calendrier de contenu à l'API

Gérer les réseaux sociaux pour un produit signifie publier régulièrement sur plusieurs plateformes, chacune avec ses propres particularités d'API, flux d'authentification, exigences média et limites de débit. Après avoir manuellement publié le même contenu sur quatre plateformes trois fois par semaine, j'ai construit un pipeline d'automatisation qui gère la planification, le formatage, le traitement média et la publication — avec une étape d'approbation pour que rien ne sorte sans revue humaine. Cet article couvre l'architecture, les pièges spécifiques à chaque plateforme et les compromis entre construire sur mesure et utiliser des outils existants.

Paysage des API de plateformes

Chaque grande plateforme a son propre écosystème d'API, et l'expérience développeur varie considérablement. Voici l'état honnête de chacune début 2026.

API Twitter/X

L'API X a connu des changements significatifs depuis la transition de la plateforme. L'API actuelle (v2) offre des endpoints de publication, de téléversement média et d'analytics. Le tier gratuit permet 1 500 publications par mois et l'accès en lecture à vos propres publications, ce qui est suffisant pour la plupart des cas d'automatisation.

Le modèle d'authentification utilise OAuth 2.0 avec PKCE pour les actions en contexte utilisateur (publier au nom d'un utilisateur) et OAuth 2.0 App-Only pour les opérations en lecture. L'API est raisonnablement bien documentée et le portail développeur fournit des informations claires sur les limites de débit.

import { TwitterApi } from 'twitter-api-v2';

const client = new TwitterApi({
  appKey: process.env.TWITTER_API_KEY!,
  appSecret: process.env.TWITTER_API_SECRET!,
  accessToken: process.env.TWITTER_ACCESS_TOKEN!,
  accessSecret: process.env.TWITTER_ACCESS_SECRET!,
});

async function postTweet(text: string, mediaIds?: string[]): Promise<string> {
  const tweet = await client.v2.tweet({
    text,
    ...(mediaIds && { media: { media_ids: mediaIds } }),
  });
  return tweet.data.id;
}

async function uploadMedia(filePath: string): Promise<string> {
  const mediaId = await client.v1.uploadMedia(filePath);
  return mediaId;
}

Limitations clés : Les publications ont une limite de 280 caractères (plus pour les utilisateurs premium). Les téléversements d'images supportent JPEG, PNG, GIF et WEBP jusqu'à 5 Mo. Les téléversements vidéo supportent MP4 jusqu'à 512 Mo mais nécessitent un upload par morceaux pour les fichiers de plus de 15 Mo. Les threads nécessitent de créer plusieurs publications avec des paramètres de reply, ce qui ajoute de la complexité.

API Instagram Graph

L'API d'Instagram fait partie de l'écosystème Meta et nécessite une Page Facebook Business liée à un Compte Instagram Professionnel. Le processus de configuration est notoirement alambiqué — vous avez besoin d'une Meta App, d'une vérification Business (pour certaines fonctionnalités) et de la bonne combinaison de permissions.

Le flux de publication est en deux étapes : d'abord créer un conteneur média, puis le publier.

async function postToInstagram(
  igUserId: string,
  imageUrl: string,
  caption: string,
  accessToken: string
): Promise<string> {
  // Step 1: Create media container
  const containerResponse = await fetch(
    `https://graph.facebook.com/v19.0/${igUserId}/media`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        image_url: imageUrl, // Must be a publicly accessible URL
        caption,
        access_token: accessToken,
      }),
    }
  );
  const container = await containerResponse.json();

  // Step 2: Publish the container
  const publishResponse = await fetch(
    `https://graph.facebook.com/v19.0/${igUserId}/media_publish`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        creation_id: container.id,
        access_token: accessToken,
      }),
    }
  );
  const result = await publishResponse.json();
  return result.id;
}

Pièges clés :

  • Les images doivent être hébergées à une URL accessible publiquement. Vous ne pouvez pas uploader des données binaires directement. Cela signifie que vous devez héberger les médias sur S3 ou un CDN avant de publier.
  • Les publications carrousel (images multiples) nécessitent de créer des conteneurs individuels pour chaque image, puis un conteneur carrousel qui les référence.
  • Les Reels (vidéo) ont des exigences supplémentaires : ratio d'aspect 9:16, entre 3 et 90 secondes, et la vidéo doit être entièrement traitée avant publication.
  • Les tokens d'accès expirent. Les tokens à longue durée durent 60 jours et nécessitent un rafraîchissement périodique.

API LinkedIn

L'API de LinkedIn pour publier du contenu utilise la "Community Management API" (anciennement "Share API"). L'authentification utilise OAuth 2.0 avec le scope w_member_social pour les profils personnels ou w_organization_social pour les pages d'entreprise.

async function postToLinkedIn(
  authorUrn: string, // "urn:li:person:xxx" or "urn:li:organization:xxx"
  text: string,
  accessToken: string
): Promise<string> {
  const response = await fetch('https://api.linkedin.com/v2/posts', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'X-Restli-Protocol-Version': '2.0.0',
      'LinkedIn-Version': '202401',
    },
    body: JSON.stringify({
      author: authorUrn,
      commentary: text,
      visibility: 'PUBLIC',
      distribution: {
        feedDistribution: 'MAIN_FEED',
        targetEntities: [],
        thirdPartyDistributionChannels: [],
      },
      lifecycleState: 'PUBLISHED',
    }),
  });

  const postId = response.headers.get('x-restli-id');
  return postId || '';
}

Limitations clés : La documentation de l'API LinkedIn est dispersée entre plusieurs versions et conventions de nommage. La limite de débit est de 100 appels API par jour par utilisateur par application pour la plupart des endpoints, ce qui est généreux pour la publication mais serré si vous récupérez aussi les analytics. Les téléversements d'images nécessitent un flux d'upload séparé avec une requête d'upload enregistrée.

API de publication de contenu TikTok

L'API de TikTok pour la publication de contenu est relativement nouvelle et plus restreinte que les autres plateformes. Vous devez postuler pour accéder à la Content Posting API spécifiquement, et l'approbation peut prendre des semaines.

Le flux de publication implique l'initialisation d'une publication, le téléversement de la vidéo, puis la confirmation de la publication :

async function postToTikTok(
  videoUrl: string,
  caption: string,
  accessToken: string
): Promise<string> {
  // Initialize the publish
  const initResponse = await fetch(
    'https://open.tiktokapis.com/v2/post/publish/video/init/',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        post_info: {
          title: caption,
          privacy_level: 'SELF_ONLY', // Start as private, change after review
          disable_duet: false,
          disable_comment: false,
          disable_stitch: false,
        },
        source_info: {
          source: 'PULL_FROM_URL',
          video_url: videoUrl,
        },
      }),
    }
  );

  const result = await initResponse.json();
  return result.data.publish_id;
}

Pièges clés : TikTok exige que toutes les publications automatisées soient signalées comme telles — il y a des flags API spécifiques pour cela. Les vidéos doivent respecter les guidelines de contenu et les exigences de format de TikTok. L'API a des limites de publication quotidiennes strictes par compte utilisateur. Plus important encore, l'écosystème API de TikTok change fréquemment, donc vérifiez la documentation actuelle avant d'implémenter.

Flux d'authentification

Les quatre plateformes utilisent OAuth 2.0, mais les détails d'implémentation diffèrent suffisamment pour nécessiter un traitement spécifique par plateforme.

La danse OAuth

Le flux général est :

  1. Rediriger l'utilisateur vers l'URL d'autorisation de la plateforme avec le client_id de votre application et les scopes demandés.
  2. L'utilisateur accorde la permission. La plateforme redirige vers votre redirect_uri avec un code d'autorisation.
  3. Echanger le code contre un access_token (et généralement un refresh_token).
  4. Utiliser l'access_token pour les appels API. Le rafraîchir quand il expire.
// Generic OAuth handler
interface OAuthConfig {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  authorizationUrl: string;
  tokenUrl: string;
  scopes: string[];
}

async function exchangeCodeForToken(
  config: OAuthConfig,
  code: string
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
  const response = await fetch(config.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: config.redirectUri,
      client_id: config.clientId,
      client_secret: config.clientSecret,
    }),
  });

  const data = await response.json();
  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresIn: data.expires_in,
  };
}

Gestion des tokens

Les tokens expirent. Les tokens Instagram durent 60 jours. Les tokens LinkedIn durent 60 jours. Les tokens Twitter durent indéfiniment (pour OAuth 1.0a) ou sont à courte durée avec des refresh tokens (pour OAuth 2.0). Les tokens TikTok durent 24 heures avec un refresh token valide 365 jours.

Vous avez besoin d'un système de rafraîchissement de tokens :

interface StoredToken {
  platform: string;
  accessToken: string;
  refreshToken: string;
  expiresAt: Date;
}

async function getValidToken(platform: string): Promise<string> {
  const stored = await db.tokens.findUnique({ where: { platform } });
  if (!stored) throw new Error(`No token stored for ${platform}`);

  // Refresh if expiring within 5 minutes
  if (stored.expiresAt < new Date(Date.now() + 5 * 60 * 1000)) {
    const refreshed = await refreshToken(platform, stored.refreshToken);
    await db.tokens.update({
      where: { platform },
      data: {
        accessToken: refreshed.accessToken,
        refreshToken: refreshed.refreshToken || stored.refreshToken,
        expiresAt: new Date(Date.now() + refreshed.expiresIn * 1000),
      },
    });
    return refreshed.accessToken;
  }

  return stored.accessToken;
}

Architecture de planification de contenu

Le système de planification est le coeur du pipeline d'automatisation. Il doit gérer les fuseaux horaires, le formatage spécifique par plateforme, le traitement média et la récupération en cas d'échec.

Modèle de données

interface ScheduledPost {
  id: string;
  content: {
    text: string;
    media?: MediaAttachment[];
    platformOverrides?: Record<string, { text?: string }>;
  };
  platforms: Platform[];
  scheduledAt: Date;  // UTC
  timezone: string;   // IANA timezone for display
  status: 'draft' | 'approved' | 'scheduled' | 'publishing' | 'published' | 'failed';
  results: Record<string, PostResult>;
  retryCount: number;
  createdBy: string;
  approvedBy?: string;
  approvedAt?: Date;
}

interface MediaAttachment {
  originalUrl: string;
  processedUrls: Record<string, string>; // Platform-specific processed versions
  type: 'image' | 'video';
  altText?: string;
}

interface PostResult {
  platform: string;
  postId?: string;
  url?: string;
  error?: string;
  publishedAt?: Date;
}

Le pattern platformOverrides

Chaque plateforme a des limites de caractères, des conventions de hashtags et des normes de formatage différentes. Plutôt que de créer des publications entièrement séparées, j'utilise un texte de base avec des overrides par plateforme :

const post: ScheduledPost = {
  id: 'post-1',
  content: {
    text: 'Excited to announce our new feature: real-time order tracking. Customers can now see exactly where their delivery is. #product #launch',
    platformOverrides: {
      twitter: {
        text: 'Just shipped: real-time order tracking. Your customers can now watch their delivery in real-time. #buildinpublic',
      },
      linkedin: {
        text: 'Excited to announce our latest feature: real-time order tracking.\n\nThis was one of the most requested features from our restaurant partners. Customers can now see exactly where their delivery is, reducing "where is my food?" support tickets by an estimated 40%.\n\nBuilt with WebSockets and a lightweight location service. Happy to share the technical architecture with anyone interested.\n\n#productdevelopment #startups #foodtech',
      },
    },
  },
  platforms: ['twitter', 'instagram', 'linkedin'],
  scheduledAt: new Date('2026-03-15T14:00:00Z'),
  timezone: 'America/New_York',
  status: 'approved',
  results: {},
  retryCount: 0,
  createdBy: 'user-1',
};

Twitter obtient une version concise. LinkedIn obtient une version plus longue et plus professionnelle. Instagram utilise le texte de base comme légende. Cette approche respecte les normes de chaque plateforme sans maintenir des calendriers de contenu complètement séparés.

Gestion des médias

Le traitement média est la partie la plus sujette aux erreurs du pipeline. Chaque plateforme a des exigences différentes pour les dimensions d'image, les tailles de fichier, les formats et les ratios d'aspect.

Pipeline de traitement

interface MediaRequirements {
  maxWidth: number;
  maxHeight: number;
  maxFileSize: number; // bytes
  supportedFormats: string[];
  aspectRatios?: { min: number; max: number };
}

const platformRequirements: Record<string, MediaRequirements> = {
  twitter: {
    maxWidth: 4096,
    maxHeight: 4096,
    maxFileSize: 5 * 1024 * 1024, // 5MB
    supportedFormats: ['jpeg', 'png', 'gif', 'webp'],
  },
  instagram: {
    maxWidth: 1440,
    maxHeight: 1440,
    maxFileSize: 8 * 1024 * 1024,
    supportedFormats: ['jpeg', 'png'],
    aspectRatios: { min: 4 / 5, max: 1.91 },
  },
  linkedin: {
    maxWidth: 4096,
    maxHeight: 4096,
    maxFileSize: 10 * 1024 * 1024,
    supportedFormats: ['jpeg', 'png', 'gif'],
  },
};

async function processMediaForPlatform(
  originalUrl: string,
  platform: string
): Promise<string> {
  const requirements = platformRequirements[platform];
  const image = await sharp(await downloadFile(originalUrl));
  const metadata = await image.metadata();

  let processed = image;

  // Resize if exceeding max dimensions
  if (
    (metadata.width && metadata.width > requirements.maxWidth) ||
    (metadata.height && metadata.height > requirements.maxHeight)
  ) {
    processed = processed.resize(requirements.maxWidth, requirements.maxHeight, {
      fit: 'inside',
      withoutEnlargement: true,
    });
  }

  // Enforce aspect ratio for Instagram
  if (requirements.aspectRatios && metadata.width && metadata.height) {
    const currentRatio = metadata.width / metadata.height;
    if (
      currentRatio < requirements.aspectRatios.min ||
      currentRatio > requirements.aspectRatios.max
    ) {
      // Crop to acceptable ratio
      const targetRatio = Math.max(
        requirements.aspectRatios.min,
        Math.min(currentRatio, requirements.aspectRatios.max)
      );
      const newWidth = Math.round(metadata.height * targetRatio);
      processed = processed.resize(newWidth, metadata.height, { fit: 'cover' });
    }
  }

  // Convert to supported format
  const format = requirements.supportedFormats.includes('webp') ? 'webp' : 'jpeg';
  const buffer = await processed.toFormat(format, { quality: 85 }).toBuffer();

  // Upload processed image and return URL
  const processedUrl = await uploadToStorage(buffer, `processed/${platform}/${Date.now()}.${format}`);
  return processedUrl;
}

L'utilisation de Sharp pour le traitement d'image garde le pipeline rapide et gère les cas particuliers comme la rotation EXIF, la conversion de profil couleur et la compatibilité de format. Pour le traitement vidéo, FFmpeg gère le transcodage, mais le traitement vidéo est significativement plus complexe et gourmand en ressources — je le décharge typiquement vers un worker dédié ou une cloud function.

Limites de débit et logique de retry

Chaque plateforme impose des limites de débit, et les atteindre de manière brutale cause des publications échouées et potentiellement des bannissements temporaires d'API.

Suivi des limites de débit

interface RateLimitState {
  platform: string;
  remaining: number;
  resetAt: Date;
  limit: number;
}

class RateLimiter {
  private limits: Map<string, RateLimitState> = new Map();

  updateFromHeaders(platform: string, headers: Headers): void {
    const remaining = parseInt(headers.get('x-rate-limit-remaining') || '100');
    const resetTimestamp = parseInt(headers.get('x-rate-limit-reset') || '0');

    this.limits.set(platform, {
      platform,
      remaining,
      resetAt: new Date(resetTimestamp * 1000),
      limit: parseInt(headers.get('x-rate-limit-limit') || '100'),
    });
  }

  async waitIfNeeded(platform: string): Promise<void> {
    const state = this.limits.get(platform);
    if (!state) return;

    if (state.remaining <= 1 && state.resetAt > new Date()) {
      const waitMs = state.resetAt.getTime() - Date.now() + 1000; // 1s buffer
      console.log(`Rate limited on ${platform}. Waiting ${waitMs}ms`);
      await new Promise((resolve) => setTimeout(resolve, waitMs));
    }
  }
}

Retry avec backoff exponentiel

Tous les échecs ne sont pas permanents. Les timeouts réseau, les erreurs serveur temporaires et les limites de débit sont retryables. Les erreurs de base de données et les échecs d'authentification ne le sont pas.

async function publishWithRetry(
  post: ScheduledPost,
  platform: string,
  maxRetries: number = 3
): Promise<PostResult> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      await rateLimiter.waitIfNeeded(platform);
      const result = await publishToPlatform(post, platform);
      return { platform, postId: result.id, url: result.url, publishedAt: new Date() };
    } catch (error) {
      const isRetryable =
        error instanceof RateLimitError ||
        error instanceof NetworkError ||
        (error instanceof ApiError && error.statusCode >= 500);

      if (!isRetryable || attempt === maxRetries) {
        return {
          platform,
          error: error instanceof Error ? error.message : 'Unknown error',
        };
      }

      const backoffMs = Math.min(1000 * Math.pow(2, attempt), 30000);
      const jitter = Math.random() * 1000;
      await new Promise((resolve) => setTimeout(resolve, backoffMs + jitter));
    }
  }

  return { platform, error: 'Max retries exceeded' };
}

Le jitter est important — sans lui, plusieurs clients en retry synchronisés sur le même calendrier de backoff créent des problèmes de "thundering herd" où ils retryent tous simultanément.

Workflow d'approbation de contenu

La publication automatisée sans revue humaine est risquée. Une erreur de formatage, une publication mal venue pendant une crise, ou un lien incorrect peuvent causer de vrais dégâts. J'inclus une étape d'approbation obligatoire dans le pipeline.

Le flux d'approbation

Content Created → Status: DRAFT
       │
       ▼
Preview Generated → Reviewer notified (Slack/email)
       │
       ▼
Reviewer Approves → Status: APPROVED → Scheduled for posting
       │
   or Rejects → Status: DRAFT (with feedback) → Back to creator

L'étape de preview est importante — elle montre au relecteur exactement ce qui sera publié sur chaque plateforme, avec les compteurs de caractères, les previews médias et les previews de liens. Cela attrape les problèmes de formatage qui ne sont pas évidents dans l'éditeur de contenu.

async function generatePreview(post: ScheduledPost): Promise<PlatformPreview[]> {
  return post.platforms.map((platform) => {
    const text =
      post.content.platformOverrides?.[platform]?.text || post.content.text;

    return {
      platform,
      text,
      characterCount: text.length,
      characterLimit: getCharacterLimit(platform),
      isOverLimit: text.length > getCharacterLimit(platform),
      mediaPreview: post.content.media?.map((m) => ({
        url: m.processedUrls[platform] || m.originalUrl,
        type: m.type,
        altText: m.altText,
      })),
      estimatedReach: getEstimatedReach(platform),
    };
  });
}

function getCharacterLimit(platform: string): number {
  const limits: Record<string, number> = {
    twitter: 280,
    instagram: 2200,
    linkedin: 3000,
    tiktok: 2200,
  };
  return limits[platform] || 2200;
}

Collecte d'analytics

Après publication, collecter les données d'engagement aide à optimiser le contenu futur. Chaque plateforme fournit des analytics via son API, mais la forme des données et la disponibilité varient.

interface PostAnalytics {
  postId: string;
  platform: string;
  collectedAt: Date;
  impressions: number;
  engagements: number;
  clicks: number;
  shares: number;
  comments: number;
  likes: number;
  engagementRate: number;
}

async function collectAnalytics(postResult: PostResult): Promise<PostAnalytics | null> {
  if (!postResult.postId) return null;

  switch (postResult.platform) {
    case 'twitter':
      return collectTwitterAnalytics(postResult.postId);
    case 'linkedin':
      return collectLinkedInAnalytics(postResult.postId);
    case 'instagram':
      return collectInstagramAnalytics(postResult.postId);
    default:
      return null;
  }
}

Je collecte les analytics à 1 heure, 24 heures et 7 jours après publication. Cela capture l'engagement immédiat, la performance quotidienne et la portée à long terme. Les données alimentent un tableau de bord simple qui montre quels types de contenu, horaires de publication et plateformes génèrent le plus d'engagement.

Construire vs acheter : l'évaluation honnête

Avant de construire un système personnalisé, demandez-vous si les outils existants répondent à vos besoins.

Quand utiliser des outils existants

Buffer (5-100 $/mois) gère la planification, la publication multi-plateforme et les analytics basiques. Il supporte Twitter, Instagram, LinkedIn, Facebook, Pinterest et TikTok. L'API est solide pour la planification programmatique. Pour la plupart des petites équipes, Buffer couvre 90 % des besoins.

Hootsuite (99 $+/mois) ajoute la collaboration d'équipe, les workflows d'approbation de contenu et des analytics plus poussés. Meilleur pour les plus grandes équipes avec plusieurs parties prenantes.

Typefully (12-29 $/mois) se spécialise dans Twitter/X avec le support des threads, la collaboration sur les brouillons et les analytics d'audience. Si Twitter est votre plateforme principale, c'est excellent.

Later (16,67 $+/mois) est fort pour les plateformes visuelles (Instagram, Pinterest, TikTok) avec la gestion de bibliothèque média et la planification visuelle du calendrier.

Quand construire sur mesure

Construire sur mesure a du sens quand :

  • Vous avez besoin d'une intégration profonde avec les systèmes internes (CMS, base de données produit, CRM)
  • Votre génération de contenu est partiellement automatisée (brouillons générés par IA, publications templétisées à partir d'événements produit)
  • Vous avez besoin de workflows d'approbation personnalisés que les outils existants ne supportent pas
  • Vous publiez à un volume où la tarification SaaS devient coûteuse (50+ publications/jour entre les comptes)
  • Les fonctionnalités API des plateformes dont vous avez besoin ne sont pas exposées par les outils tiers

L'approche hybride

Utilisez un outil de planification (Buffer, Hootsuite) pour le contenu manuel et construisez de l'automatisation personnalisée uniquement pour les publications programmatiques. Beaucoup d'outils de planification ont des API qui vous permettent de créer des publications de manière programmatique tout en utilisant leur tableau de bord pour la revue et l'approbation.

// Using Buffer's API for scheduling
async function scheduleViaBuffer(
  profileIds: string[],
  text: string,
  mediaUrls: string[],
  scheduledAt: Date
): Promise<string> {
  const response = await fetch('https://api.bufferapp.com/1/updates/create.json', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      access_token: process.env.BUFFER_ACCESS_TOKEN!,
      text,
      'profile_ids[]': profileIds.join(','),
      scheduled_at: scheduledAt.toISOString(),
      ...(mediaUrls.length && { 'media[photo]': mediaUrls[0] }),
    }),
  });

  const result = await response.json();
  return result.updates[0].id;
}

Cela vous donne la fiabilité et l'interface d'un outil établi tout en gardant la flexibilité programmatique du code personnalisé.

Notifications webhook

Quand une publication est publiée, échoue, ou reçoit un engagement significatif, le système devrait notifier l'équipe. J'utilise des webhooks pour pousser les événements vers Slack.

async function notifySlack(event: PostEvent): Promise<void> {
  const color = event.type === 'published' ? '#36a64f' : '#ff0000';
  const title =
    event.type === 'published'
      ? `Posted to ${event.platform}`
      : `Failed to post to ${event.platform}`;

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      attachments: [
        {
          color,
          title,
          text: event.type === 'published'
            ? `"${event.text.substring(0, 100)}..." — ${event.url}`
            : `Error: ${event.error}`,
          fields: [
            { title: 'Platform', value: event.platform, short: true },
            { title: 'Time', value: new Date().toLocaleString(), short: true },
          ],
        },
      ],
    }),
  });
}

Patterns de gestion d'erreurs

Les API de réseaux sociaux sont peu fiables. Les serveurs tombent, les limites de débit changent, le traitement média échoue. Le système doit gérer les échecs gracieusement sans perdre de contenu.

La dead letter queue

Les publications qui échouent après toutes les tentatives vont dans une dead letter queue pour revue manuelle :

async function handleFailedPost(post: ScheduledPost, platform: string, error: string): Promise<void> {
  // Update post status
  await db.scheduledPosts.update({
    where: { id: post.id },
    data: {
      status: 'failed',
      results: {
        ...post.results,
        [platform]: { platform, error },
      },
    },
  });

  // Add to dead letter queue
  await db.failedPosts.create({
    data: {
      postId: post.id,
      platform,
      error,
      originalContent: post.content,
      failedAt: new Date(),
      resolved: false,
    },
  });

  // Notify team
  await notifySlack({
    type: 'failed',
    platform,
    text: post.content.text,
    error,
  });
}

Un tableau de bord affiche toutes les publications échouées avec des boutons de retry. La plupart des échecs sont transitoires et réussissent en retry manuel. Les échecs persistants indiquent généralement l'expiration de tokens ou des changements d'API qui nécessitent des mises à jour de code.

Gestion des succès partiels

Une publication ciblant trois plateformes pourrait réussir sur deux et échouer sur une. Le système doit suivre les résultats par plateforme indépendamment plutôt que de traiter la publication entière comme échouée :

async function publishToAllPlatforms(post: ScheduledPost): Promise<void> {
  const results = await Promise.allSettled(
    post.platforms.map((platform) => publishWithRetry(post, platform))
  );

  const postResults: Record<string, PostResult> = {};
  let hasFailure = false;

  results.forEach((result, index) => {
    const platform = post.platforms[index];
    if (result.status === 'fulfilled') {
      postResults[platform] = result.value;
      if (result.value.error) hasFailure = true;
    } else {
      postResults[platform] = { platform, error: result.reason.message };
      hasFailure = true;
    }
  });

  await db.scheduledPosts.update({
    where: { id: post.id },
    data: {
      status: hasFailure ? 'failed' : 'published',
      results: postResults,
    },
  });
}

L'utilisation de Promise.allSettled au lieu de Promise.all garantit qu'un échec sur une plateforme n'empêche pas la publication sur les autres.

Assembler le tout

Le pipeline d'automatisation complet ressemble à ceci :

  1. Le contenu est créé via un tableau de bord web ou une API (avec rédaction assistée par IA optionnelle).
  2. Les médias sont téléversés et traités pour chaque plateforme cible avec Sharp.
  3. La publication entre dans une file d'approbation où un relecteur voit les previews spécifiques par plateforme.
  4. A l'approbation, la publication est planifiée dans la base de données avec un horodatage UTC.
  5. Un cron job (ou cloud function planifiée) vérifie les publications dues dans la minute suivante.
  6. Pour chaque publication due, le publisher s'authentifie auprès de chaque plateforme, gère les téléversements média et publie.
  7. Les résultats sont enregistrés par plateforme. Les échecs déclenchent des retries avec backoff exponentiel.
  8. Les retries épuisés vont dans la dead letter queue avec notifications Slack.
  9. Les analytics sont collectés aux intervalles 1h, 24h et 7j.

Est-ce sur-ingénieré pour un fondateur solo qui publie deux fois par semaine ? Absolument — utilisez Buffer. Mais pour un produit avec des besoins de contenu programmatique, des publications événementielles ou des workflows d'approbation complexes, un pipeline personnalisé se rentabilise en cohérence et contrôle. Les API des plateformes sont assez stables et l'outillage assez mature pour que l'implémentation soit de l'ingénierie directe plutôt qu'un combat contre des API mal documentées. Ce n'était pas le cas il y a même deux ans.

DU

Danil Ulmashev

Full Stack Developer

Intéressé par une collaboration ?