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 :
- Une table
nurturing_stepqui stocke l'état de chaque contact dans la séquence. - Un cron Vercel (
vercel.json) qui déclenche une route API toutes les heures. - 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.