Mettre en place un cron de nurturing e-mail sans service tiers

Les plateformes de marketing automation (Customer.io, ActiveCampaign, Brevo) facturent au contact et imposent leur modèle de données. Pour un produit B2B avec un volume de leads maîtrisé et une séquence de nurturing simple (5 à 10 e-mails déclenchés sur événements), un cron applicatif couplé à votre base existante suffit largement, sans point de couplage externe supplémentaire.

Pourquoi s'en passer

Trois raisons concrètes, pas de posture anti-SaaS :

  • Coût marginal nul : un cron Vercel + une table Postgres coûtent zéro au-delà de votre infra existante, contre 50-300 €/mois dès que vous dépassez quelques milliers de contacts sur la plupart des outils.
  • Données in-house : pas d'export de vos leads vers un tiers, pas de synchronisation à maintenir entre votre CRM interne et l'outil externe.
  • Logique métier versionnée avec le code : la séquence de relance vit dans votre repo, testable en CI, revue en PR — pas dans une UI de workflow externe non versionnable.

En contrepartie, vous perdez le tracking d'ouverture/clic sophistiqué et l'A/B testing intégré. Si ces besoins sont critiques, ce n'est pas le bon pattern.

Architecture retenue

Trois briques :

  1. Une table nurturing_step qui stocke l'état de chaque contact dans la séquence.
  2. Un cron Vercel (vercel.json) qui déclenche une route API toutes les heures.
  3. Un envoi via un provider SMTP/API transactionnel (Resend, SES, Postmark) — ce n'est pas un service de nurturing, juste un relais d'envoi, l'orchestration reste chez vous.
{
  "crons": [
    { "path": "/api/cron/nurturing", "schedule": "0 * * * *" }
  ]
}

Schéma Prisma minimal :

model NurturingStep {
  id           String   @id @default(cuid())
  contactId    String
  sequenceKey  String   // ex: "trial-expired"
  stepIndex    Int      // position dans la séquence (0, 1, 2...)
  scheduledAt  DateTime
  sentAt       DateTime?
  status       String   @default("pending") // pending | sent | failed | cancelled
  contact      Contact  @relation(fields: [contactId], references: [id])

  @@index([status, scheduledAt])
}

L'index sur (status, scheduledAt) est indispensable : c'est lui qui rend la requête du cron performante quand la table grossit.

Définir la séquence en code, pas en base

La séquence (contenu, délais, conditions d'arrêt) reste dans un fichier TypeScript versionné :

// lib/nurturing/sequences.ts
export const sequences = {
  "trial-expired": [
    { delayHours: 0, template: "trial-expired-day0" },
    { delayHours: 72, template: "trial-expired-day3" },
    { delayHours: 168, template: "trial-expired-day7" },
  ],
};

Quand un événement métier déclenche une séquence (ex: fin de trial), vous insérez les NurturingStep correspondants avec scheduledAt calculé à partir de delayHours. Le cron n'a plus qu'à consommer ce qui est dû.

Le endpoint cron

// app/api/cron/nurturing/route.ts
import { db } from "@/lib/db";
import { sendTransactionalEmail } from "@/lib/mail";

export async function GET(req: Request) {
  const auth = req.headers.get("authorization");
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const dueSteps = await db.nurturingStep.findMany({
    where: { status: "pending", scheduledAt: { lte: new Date() } },
    include: { contact: true },
    take: 200, // borne

Le Template Puppeteer B2B, gratuit

Un script d'automatisation prêt à l'emploi pour vos scrapers et générateurs de PDF B2B. Envoyé par e-mail, sans spam.

Partenaire

Hébergez vos APIs sur une infrastructure rapide

Hostinger propose un hébergement cloud et VPS haute performance avec NVMe, CDN intégré et déploiement Git — idéal pour vos back-ends et APIs JSON.