Skip to main content
RapidDev - Software Development Agency

How to Build a Email Automation with Replit

Build a Mailchimp-alternative email automation platform in Replit in 2-4 hours. Create contact lists, design drip sequences with delay-based scheduling, send campaigns via SendGrid, and track opens and clicks through SendGrid's Event Webhook. Uses Express, PostgreSQL with Drizzle ORM, SendGrid API, and a Scheduled Deployment for the send queue.

What you'll build

  • Contact management system with tags, import via CSV, and unsubscribe handling
  • Campaign builder with recipient list selection and scheduled or immediate sending
  • Batch send queue processed by a Scheduled Deployment with SendGrid rate limiting
  • Drip sequence engine with configurable delay steps and automatic enrollment progression
  • Open and click tracking via SendGrid Event Webhook with real-time campaign stats
  • Idempotent webhook handler that updates contact statuses on bounces and unsubscribes
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced16 min read2-4 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a Mailchimp-alternative email automation platform in Replit in 2-4 hours. Create contact lists, design drip sequences with delay-based scheduling, send campaigns via SendGrid, and track opens and clicks through SendGrid's Event Webhook. Uses Express, PostgreSQL with Drizzle ORM, SendGrid API, and a Scheduled Deployment for the send queue.

What you're building

Email automation is the highest-ROI marketing channel for most early-stage businesses. Off-the-shelf tools like Mailchimp charge $20-100/month even at small list sizes, and they lock you into their templates. Building your own gives you complete control over deliverability, data, and pricing — your only cost is SendGrid's API fee (~$0.001 per email on the free tier up to 100/day).

Replit Agent generates the full backend: contact tables, campaign schema, drip sequence structure, and Express routes. The key architectural decision is separating the web API from the email sending process. Sending happens in a Scheduled Deployment — a separate Replit deployment that runs on a cron schedule — rather than inline in the API request. This prevents timeouts on large campaigns and allows retry logic for failed sends.

The SendGrid Event Webhook brings tracking back to your system. When a recipient opens or clicks an email, SendGrid sends an event to your /api/webhooks/sendgrid endpoint. Your handler updates the contact's record and the campaign's stats. This webhook only works after deployment — no incoming connections in dev. Deploy on Reserved VM to keep the webhook receiver always-on.

Final result

A working email automation platform where you can import contacts, create drip sequences, send campaigns with open/click tracking, and watch statistics update in real time — all running on Replit with SendGrid delivery.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
SendGridEmail Delivery

Prerequisites

  • A Replit account (free tier is sufficient for the app, Replit Core for Scheduled Deployments)
  • A SendGrid account (free at sendgrid.com, includes 100 emails/day forever)
  • A verified sender email in SendGrid (Settings → Sender Authentication)
  • SendGrid API key with Mail Send permission (Settings → API Keys)

Build steps

1

Generate the project schema and routes with Replit Agent

The schema is the most complex part of this project. Getting it right in the first prompt saves hours of migration work. Use a detailed Agent prompt to generate everything at once.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build an email automation system with Express and PostgreSQL using Drizzle ORM.
3// Create these tables in shared/schema.ts:
4// - contacts: id serial pk, email text unique, name text, tags text[],
5// status text default 'active' (active/unsubscribed/bounced),
6// source text (import/form/api), subscribed_at timestamp, unsubscribed_at timestamp
7// - email_lists: id serial pk, name text, description text, created_at timestamp
8// - list_contacts: id serial pk, list_id integer references email_lists,
9// contact_id integer references contacts, added_at timestamp,
10// UNIQUE on (list_id, contact_id)
11// - campaigns: id serial pk, name text, subject text, from_name text, from_email text,
12// body_html text, list_id integer references email_lists,
13// status text default 'draft' (draft/scheduled/sending/sent/cancelled),
14// scheduled_at timestamp, sent_at timestamp, created_at timestamp
15// - campaign_sends: id serial pk, campaign_id integer references campaigns,
16// contact_id integer references contacts,
17// status text default 'pending' (pending/sent/delivered/bounced/failed),
18// sent_at timestamp, UNIQUE on (campaign_id, contact_id)
19// - drip_sequences: id serial pk, name text, trigger_event text (signup/manual),
20// list_id integer references email_lists, is_active boolean default true, created_at timestamp
21// - drip_steps: id serial pk, sequence_id integer references drip_sequences,
22// position integer, delay_hours integer, subject text, body_html text
23// - drip_enrollments: id serial pk, sequence_id integer references drip_sequences,
24// contact_id integer references contacts, current_step integer default 0,
25// status text default 'active' (active/completed/paused/unsubscribed),
26// enrolled_at timestamp, next_send_at timestamp, UNIQUE on (sequence_id, contact_id)
27// - email_events: id serial pk, campaign_send_id integer, event_type text
28// (open/click/bounce/unsubscribe), metadata jsonb, created_at timestamp
29// Install @sendgrid/mail. Set up Replit Auth. Bind server to 0.0.0.0.

Pro tip: After Agent generates the schema, open Drizzle Studio (database icon in sidebar) to verify all tables were created with the correct column types before adding data.

Expected result: Agent creates shared/schema.ts with all 9 tables, server/index.js, and installs @sendgrid/mail. The database initializes with Drizzle migrations.

2

Add SENDGRID_API_KEY to Secrets and build the campaign routes

Store the SendGrid API key securely before writing any code that uses it. Then build the campaign creation and scheduling routes — the POST /api/campaigns/:id/send route queues sends by creating campaign_sends rows.

prompt.txt
1// 1. Open Secrets panel (lock icon in sidebar)
2// Add: SENDGRID_API_KEY = your-sendgrid-api-key-here
3//
4// 2. Prompt to type into Replit Agent:
5// Add campaign routes to server/routes/campaigns.js:
6//
7// POST /api/campaigns — create campaign: {name, subject, from_name, from_email, body_html, list_id}
8// GET /api/campaigns — list user's campaigns with send counts per status
9// PUT /api/campaigns/:id — update draft campaign
10//
11// POST /api/campaigns/:id/send — queue for sending:
12// 1. Validate campaign status is 'draft' or 'scheduled'
13// 2. Get all contacts in the campaign's list_id where contact status = 'active'
14// 3. Insert one campaign_sends row per contact with status='pending'
15// Use INSERT ... ON CONFLICT (campaign_id, contact_id) DO NOTHING
16// to safely handle re-queuing attempts
17// 4. Update campaign status to 'sending' if scheduled_at is null (send now)
18// or 'scheduled' if scheduled_at is in the future
19// 5. Return {queued: contactCount}
20//
21// GET /api/campaigns/:id/stats — return:
22// total_sends, sent_count, open_count, click_count, bounce_count
23// calculated from campaign_sends joined with email_events
24// Calculate open_rate = open_count / sent_count * 100

Expected result: POST /api/campaigns/:id/send creates pending rows in campaign_sends for every active contact in the list. The campaign status changes to 'sending'.

3

Build the Scheduled Deployment send queue processor

The send queue is the heart of the system. A separate Replit Scheduled Deployment runs every 15 minutes, picks up pending sends, and batches them through SendGrid while respecting rate limits and retry logic.

scripts/processQueue.js
1const sgMail = require('@sendgrid/mail');
2const { db } = require('./server/db');
3const { campaigns, campaignSends, contacts, dripEnrollments, dripSteps, dripSequences } = require('./shared/schema');
4const { eq, and, lte, sql } = require('drizzle-orm');
5
6sgMail.setApiKey(process.env.SENDGRID_API_KEY);
7
8async function processCampaignSends() {
9 // Get campaigns with status='sending'
10 const activeCampaigns = await db.select().from(campaigns)
11 .where(eq(campaigns.status, 'sending'));
12
13 for (const campaign of activeCampaigns) {
14 // Get next 100 pending sends for this campaign
15 const pendingSends = await db.select({
16 sendId: campaignSends.id,
17 email: contacts.email,
18 name: contacts.name
19 })
20 .from(campaignSends)
21 .innerJoin(contacts, eq(campaignSends.contactId, contacts.id))
22 .where(and(eq(campaignSends.campaignId, campaign.id), eq(campaignSends.status, 'pending')))
23 .limit(100);
24
25 if (pendingSends.length === 0) {
26 // All sends complete — mark campaign as sent
27 await db.update(campaigns).set({ status: 'sent', sentAt: new Date() })
28 .where(eq(campaigns.id, campaign.id));
29 continue;
30 }
31
32 for (const send of pendingSends) {
33 try {
34 await sgMail.send({
35 to: send.email,
36 from: { name: campaign.fromName, email: campaign.fromEmail },
37 subject: campaign.subject,
38 html: campaign.bodyHtml.replace('{{name}}', send.name || 'there')
39 });
40 await db.update(campaignSends).set({ status: 'sent', sentAt: new Date() })
41 .where(eq(campaignSends.id, send.sendId));
42 } catch (err) {
43 await db.update(campaignSends).set({ status: 'failed' })
44 .where(eq(campaignSends.id, send.sendId));
45 console.error(`Send failed for ${send.email}:`, err.response?.body || err.message);
46 }
47 }
48
49 // Small delay between batches to respect SendGrid rate limits
50 await new Promise(r => setTimeout(r, 1000));
51 }
52}
53
54async function processDripEnrollments() {
55 const dueEnrollments = await db.select()
56 .from(dripEnrollments)
57 .where(and(
58 eq(dripEnrollments.status, 'active'),
59 lte(dripEnrollments.nextSendAt, new Date())
60 ));
61
62 for (const enrollment of dueEnrollments) {
63 const nextStep = await db.query.dripSteps.findFirst({
64 where: and(
65 eq(dripSteps.sequenceId, enrollment.sequenceId),
66 eq(dripSteps.position, enrollment.currentStep)
67 )
68 });
69
70 if (!nextStep) {
71 await db.update(dripEnrollments).set({ status: 'completed' })
72 .where(eq(dripEnrollments.id, enrollment.id));
73 continue;
74 }
75
76 const contact = await db.query.contacts.findFirst({
77 where: eq(contacts.id, enrollment.contactId)
78 });
79
80 if (contact?.status === 'active') {
81 await sgMail.send({
82 to: contact.email,
83 from: { name: 'Your Company', email: process.env.FROM_EMAIL },
84 subject: nextStep.subject,
85 html: nextStep.bodyHtml.replace('{{name}}', contact.name || 'there')
86 });
87 }
88
89 // Advance to next step
90 const followingStep = await db.query.dripSteps.findFirst({
91 where: and(
92 eq(dripSteps.sequenceId, enrollment.sequenceId),
93 eq(dripSteps.position, enrollment.currentStep + 1)
94 )
95 });
96
97 await db.update(dripEnrollments).set({
98 currentStep: enrollment.currentStep + 1,
99 nextSendAt: followingStep
100 ? new Date(Date.now() + followingStep.delayHours * 3600000)
101 : null,
102 status: followingStep ? 'active' : 'completed'
103 }).where(eq(dripEnrollments.id, enrollment.id));
104 }
105}
106
107(async () => {
108 await processCampaignSends();
109 await processDripEnrollments();
110 process.exit(0);
111})();

Pro tip: The 1-second delay between batches of 100 sends keeps you under SendGrid's free tier limit of 100 emails/day. Upgrade to SendGrid's Essentials plan ($19.95/month, 50,000 emails) for production volume.

Expected result: Running node scripts/processQueue.js from the Shell sends pending campaign emails and advances drip enrollments. Schedule this to run every 15 minutes via a Scheduled Deployment.

4

Build the SendGrid Event Webhook for tracking

SendGrid can POST open and click events to your Express server. This webhook handler updates contact statuses and campaign statistics in real time. It only works after deployment.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add the SendGrid webhook handler to server/routes/webhooks.js:
3//
4// POST /api/webhooks/sendgrid
5// SendGrid sends an array of events in the request body
6// Each event has: email, event (open/click/bounce/unsubscribe/delivered),
7// sg_event_id, sg_message_id, timestamp, url (for click events)
8//
9// For each event in the array:
10// 1. Find the matching campaign_sends row by joining contacts.email = event.email
11// and filtering by recent sent_at (within 30 days)
12// 2. If found, insert into email_events: {campaign_send_id, event_type: event.event,
13// metadata: {url: event.url}, created_at: new Date(event.timestamp * 1000)}
14// 3. If event.event === 'bounce' or 'unsubscribe':
15// UPDATE contacts SET status = (event.event === 'bounce' ? 'bounced' : 'unsubscribed'),
16// unsubscribed_at = now() WHERE email = event.email
17// 4. Always return 200 — SendGrid retries on non-2xx responses
18//
19// Register the webhook URL in SendGrid:
20// Settings → Mail Settings → Event Webhook
21// HTTP Post URL: https://your-deployment-url.repl.co/api/webhooks/sendgrid
22// Select: Delivered, Opened, Clicked, Bounced, Unsubscribed

Pro tip: SendGrid sends events as an array — sometimes batching multiple events in one POST. Your handler must loop over req.body (an array), not treat it as a single event object.

Expected result: After deployment and webhook registration in SendGrid, opening a test email updates the campaign stats. Check the email_events table in Drizzle Studio to see open/click events logged.

5

Build the drip sequence builder and contact import

Drip sequences are chains of timed emails. The sequence builder lets you create steps with delay_hours between them. The contact import handles CSV uploads and adds contacts to lists in bulk.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add these routes to server/routes/contacts.js:
3//
4// POST /api/contacts/import — CSV import
5// Accept multipart form with CSV file using multer
6// Parse CSV with csv-parse: expect columns 'email' and optionally 'name', 'tags'
7// For each row: INSERT INTO contacts (email, name, tags) ON CONFLICT (email) DO NOTHING
8// Return {imported: count, skipped: duplicateCount}
9//
10// POST /api/lists/:id/contacts — add contact by email to a list
11// Find or create contact by email, then insert list_contacts row
12//
13// Add these routes to server/routes/drip.js:
14//
15// POST /api/drip-sequences — create sequence: {name, trigger_event, list_id}
16// POST /api/drip-sequences/:id/steps — add step: {delay_hours, subject, body_html, position}
17// GET /api/drip-sequences/:id — sequence with all steps ordered by position
18// DELETE /api/drip-sequences/:id/steps/:stepId — remove a step
19//
20// POST /api/drip-sequences/:id/enroll — enroll a contact
21// Body: {contactId} — contact must be in the sequence's list
22// INSERT INTO drip_enrollments (sequence_id, contact_id, current_step=0,
23// next_send_at = now() + first step's delay_hours)
24// ON CONFLICT (sequence_id, contact_id) DO NOTHING
25//
26// GET /api/drip-sequences/:id/enrollments — list enrollments with contact info and status

Expected result: Importing a CSV of 50 contacts adds them to the contacts table. Enrolling them in a drip sequence creates rows in drip_enrollments with next_send_at set to the first step's delay from now.

6

Deploy on Reserved VM and set up the Scheduled Deployment

Deploy the main app and set up a Scheduled Deployment to run the send queue processor every 15 minutes. Reserved VM ensures the SendGrid webhook receiver is always available.

prompt.txt
1// Deployment plan:
2//
3// 1. Open Secrets panel (lock icon) and verify:
4// SENDGRID_API_KEY, SESSION_SECRET, FROM_EMAIL (verified sender)
5// DATABASE_URL (auto-set by Replit)
6//
7// 2. Ensure server/index.js binds to 0.0.0.0:
8// app.listen(process.env.PORT || 3000, '0.0.0.0', () => {...})
9//
10// 3. Deploy main app on Reserved VM:
11// Deploy → Reserved VM (ensures webhook receiver is always-on)
12// Note your deployment URL: https://your-repl.your-username.repl.co
13//
14// 4. Create Scheduled Deployment:
15// Deploy → Scheduled → every 15 minutes
16// Run command: node scripts/processQueue.js
17// This runs the send queue processor on a cron schedule
18//
19// 5. Register SendGrid webhook:
20// SendGrid Dashboard → Settings → Mail Settings → Event Webhook
21// HTTP Post URL: https://your-deployment-url.repl.co/api/webhooks/sendgrid
22// Check: Delivered, Opened, Clicked, Bounced, Unsubscribed → Save
23//
24// 6. Test: create a contact, add to a list, create a campaign, queue sends
25// Wait for next Scheduled Deployment run (or trigger manually from Scripts)
26// Check Drizzle Studio: campaign_sends should show status='sent'

Pro tip: Replit's free plan does not include Scheduled Deployments. You need Replit Core ($25/month) to set up the automated send queue. Alternatively, trigger processQueue.js manually from the Shell during testing.

Expected result: The main app is live on Reserved VM, the scheduled processor runs every 15 minutes, and the SendGrid webhook delivers open/click events to your endpoint.

Complete code

scripts/processQueue.js
1const sgMail = require('@sendgrid/mail');
2const { db } = require('./server/db');
3const { campaigns, campaignSends, contacts, dripEnrollments, dripSteps } = require('./shared/schema');
4const { eq, and, lte } = require('drizzle-orm');
5
6sgMail.setApiKey(process.env.SENDGRID_API_KEY);
7
8async function processCampaignSends() {
9 const activeCampaigns = await db.select().from(campaigns)
10 .where(eq(campaigns.status, 'sending'));
11
12 for (const campaign of activeCampaigns) {
13 const pendingSends = await db.select({
14 sendId: campaignSends.id,
15 email: contacts.email,
16 name: contacts.name
17 }).from(campaignSends)
18 .innerJoin(contacts, eq(campaignSends.contactId, contacts.id))
19 .where(and(eq(campaignSends.campaignId, campaign.id), eq(campaignSends.status, 'pending')))
20 .limit(100);
21
22 if (pendingSends.length === 0) {
23 await db.update(campaigns).set({ status: 'sent', sentAt: new Date() })
24 .where(eq(campaigns.id, campaign.id));
25 continue;
26 }
27
28 for (const send of pendingSends) {
29 try {
30 await sgMail.send({
31 to: send.email,
32 from: { name: campaign.fromName, email: campaign.fromEmail },
33 subject: campaign.subject,
34 html: campaign.bodyHtml.replace(/{{name}}/g, send.name || 'there')
35 });
36 await db.update(campaignSends)
37 .set({ status: 'sent', sentAt: new Date() })
38 .where(eq(campaignSends.id, send.sendId));
39 } catch (err) {
40 await db.update(campaignSends).set({ status: 'failed' })
41 .where(eq(campaignSends.id, send.sendId));
42 console.error('Send failed:', err.response?.body || err.message);
43 }
44 await new Promise(r => setTimeout(r, 50));
45 }
46 }
47}
48
49async function processDripEnrollments() {
50 const due = await db.select().from(dripEnrollments)
51 .where(and(eq(dripEnrollments.status, 'active'), lte(dripEnrollments.nextSendAt, new Date())));
52
53 for (const enrollment of due) {
54 const step = await db.query.dripSteps.findFirst({
55 where: and(eq(dripSteps.sequenceId, enrollment.sequenceId), eq(dripSteps.position, enrollment.currentStep))
56 });
57 if (!step) {
58 await db.update(dripEnrollments).set({ status: 'completed' }).where(eq(dripEnrollments.id, enrollment.id));
59 continue;
60 }

Customization ideas

A/B testing subject lines

Add an ab_test_subject field to campaigns. Split the contact list 50/50 on queue creation — half get the original subject, half get the test subject. The stats endpoint shows open rates for both variants so you can see which performs better.

Unsubscribe landing page

Add an unsubscribe token field to campaign_sends. Include a tokenized unsubscribe link in every email (/unsubscribe?token=xxx). The unsubscribe route verifies the token, sets contact status to 'unsubscribed', and shows a branded confirmation page — GDPR compliant and no SendGrid dependency.

Contact tagging from link clicks

When a contact clicks a specific link (e.g., /pricing), add a tag to their record. Use the SendGrid click event webhook with the URL field to identify which link was clicked and run an UPDATE contacts SET tags = tags || '{clicked-pricing}' WHERE email = :email.

Common pitfalls

Pitfall: Sending all campaign emails in a single API request

How to avoid: Always queue sends (insert pending rows) in the API, then process them asynchronously via a Scheduled Deployment. The API returns immediately; the scheduler handles delivery in batches.

Pitfall: Not handling bounced contacts

How to avoid: The SendGrid Event Webhook bounce events update contact status to 'bounced'. The campaign send queue skips contacts where status != 'active'. Always process bounce events promptly.

Pitfall: Forgetting the PostgreSQL sleep reconnection issue in the scheduled processor

How to avoid: Configure the PostgreSQL Pool with connectionTimeoutMillis: 5000 and implement a retry on connection error. The pool reconnects automatically on the next call after a sleep.

Pitfall: Storing the SendGrid API key in code instead of Replit Secrets

How to avoid: Always store SENDGRID_API_KEY in the Secrets panel (lock icon in sidebar). Access it in code only via process.env.SENDGRID_API_KEY.

Best practices

  • Process campaign sends in batches of 100 with a 50ms delay between each send to stay within SendGrid's rate limits on free tier accounts.
  • Use INSERT ... ON CONFLICT DO NOTHING when creating campaign_sends rows to safely handle re-queuing attempts without creating duplicate send records.
  • Always include an unsubscribe mechanism in every marketing email — this is legally required in most countries (CAN-SPAM, GDPR). Handle the SendGrid unsubscribe webhook to automatically update contact status.
  • Store the SendGrid API key and sender email address in Replit Secrets (lock icon), and replicate them in Deployment Secrets before going live.
  • Use Drizzle Studio (database icon in sidebar) to monitor the campaign_sends table during testing — you can see which sends are pending, sent, or failed in real time.
  • Deploy the main app on Reserved VM rather than Autoscale — the SendGrid Event Webhook requires an always-on server to receive tracking events reliably.
  • Test your drip sequence end-to-end by enrolling yourself as a contact with a short delay_hours (e.g., 0.01 hours = 36 seconds) so you receive the first email within a minute.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an email automation system with Express and PostgreSQL. I have a drip_enrollments table with columns: sequence_id, contact_id, current_step, status, next_send_at. I have a drip_steps table with columns: sequence_id, position (starting at 0), delay_hours, subject, body_html. I need a batch processor that: (1) finds enrollments where next_send_at <= now() and status = 'active', (2) fetches the step at current_step position for each enrollment, (3) sends the email via SendGrid, (4) advances current_step by 1, (5) sets next_send_at = now() + next step's delay_hours, or marks status = 'completed' if no next step exists. Help me write this in Node.js with Drizzle ORM.

Build Prompt

Add a campaign performance dashboard to the email automation system. Build GET /api/campaigns/:id/stats that returns: total_sends, sent_count, open_count, click_count, bounce_count, unsubscribe_count, open_rate (open_count/sent_count), click_rate (click_count/sent_count). Also add GET /api/campaigns/:id/activity — the last 50 email events with contact name, event type, and timestamp — for the real-time activity feed in the dashboard UI.

Frequently asked questions

Can I use Resend or Postmark instead of SendGrid?

Yes. Replace @sendgrid/mail with the Resend SDK (resend) or node-postmark. The send queue processor only needs to change the sendEmail function — the database schema, routes, and drip logic stay the same. Resend offers a generous free tier (3,000 emails/month) and is a popular SendGrid alternative.

How do I avoid my emails going to spam?

Three main factors: (1) verify your sender domain in SendGrid (Settings → Sender Authentication → Domain Authentication), (2) only email contacts who opted in, (3) honor unsubscribes immediately. Also set up SPF, DKIM, and DMARC DNS records on your domain — SendGrid's Domain Authentication wizard does this for you.

Do I need a paid Replit plan for Scheduled Deployments?

Yes. Replit Core ($25/month) includes Scheduled Deployments. On the free plan, you can still run node scripts/processQueue.js manually from the Shell, or trigger it from a webhook call. For production use, Replit Core is required for the automated 15-minute cron.

What's the maximum list size this handles?

The queue-based architecture handles lists of any size. A 100,000-contact campaign creates 100,000 rows in campaign_sends, then processes them in batches of 100 across many Scheduled Deployment runs. PostgreSQL's 10GB free limit stores approximately 1 million campaign_sends rows before cleanup is needed.

How do I implement double opt-in?

Add a confirmed_at column to contacts with default null. On form signup, set status='pending'. Send a confirmation email with a token link. A /confirm?token=xxx route verifies the token and sets confirmed_at = now() and status='active'. The campaign queue only processes contacts where status='active'.

Why must the app be on Reserved VM instead of Autoscale?

Autoscale scales to zero when there's no traffic, which means your Express server is not running. SendGrid's Event Webhook delivers opens and clicks asynchronously — if your server is asleep when a webhook arrives, you miss the tracking event. Reserved VM keeps the server always-on so no events are lost.

Can RapidDev help build a custom email marketing platform?

Yes. RapidDev has built 600+ apps and can add features like visual drag-and-drop email builder, advanced segmentation with behavioral triggers, and multi-team workspaces. Book a free consultation at rapidevelopers.com.

How do I handle GDPR compliance?

Three requirements: (1) record consent source (form/import/api) in the contacts.source field with a timestamp, (2) provide a working unsubscribe link in every email, (3) process unsubscribe requests within 24 hours. The SendGrid unsubscribe webhook handles #2 and #3 automatically in this build.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.