The Real Problem: Messages Arriving at the Wrong Time
Sending a promotional message at 2 AM is not a beginner's mistake — it's a negligence mistake. In 2026, given the maturity of the WhatsApp Business API ecosystem, there is no excuse for not scheduling your sends with surgical precision. This tutorial explains how to do it right from the start: architecture, code, costs, and the traps that will bite you if you don't know them.

Prerequisites
Before writing a single line of code, you need to have these in order:
- Verified and approved Meta Business Manager account.
- Phone number registered on the WhatsApp Business API (Meta Cloud API or a BSP provider like Twilio, 360dialog, or Infobip).
- Long-lived Access Token (or an automatic refresh system — 24-hour tokens will cause you headaches in production).
- Database with your users' phone numbers and their stored timezones. This point is critical and most people ignore it.
- Task queue (BullMQ over Redis, or Celery if you use Python). Do not try to schedule messages with
setTimeoutin Node.js — that does not survive a server restart. - Webhook configured to receive delivery confirmations and update the status of each message.
Step 1: Structure Your Database for Timezones
The most common mistake: storing only the phone number. Also store the user's timezone from the moment of registration or their first interaction.
CREATE TABLE subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL UNIQUE,
display_name VARCHAR(100),
timezone VARCHAR(50) NOT NULL DEFAULT 'America/Mexico_City',
opted_in BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
When scheduling a mass send, the query you need converts the desired send time (for example, 10:00 AM) to UTC according to each user's timezone:
SELECT
id,
phone,
timezone,
(NOW()::DATE + '10:00:00'::TIME) AT TIME ZONE timezone AT TIME ZONE 'UTC' AS send_at_utc
FROM subscribers
WHERE opted_in = TRUE;
This gives you the exact UTC time to individually queue each message.
Step 2: Set Up the Message Queue with BullMQ
Install the necessary dependencies:
npm install bullmq ioredis axios
Create the worker that processes messages:
// worker.js
import { Worker } from 'bullmq';
import axios from 'axios';
const META_TOKEN = process.env.META_ACCESS_TOKEN;
const PHONE_NUMBER_ID = process.env.META_PHONE_NUMBER_ID;
const worker = new Worker('whatsapp-scheduled', async (job) => {
const { phone, templateName, languageCode, components } = job.data;
const response = await axios.post(
`https://graph.facebook.com/v20.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
to: phone,
type: 'template',
template: {
name: templateName,
language: { code: languageCode },
components: components || [],
},
},
{
headers: {
Authorization: `Bearer ${META_TOKEN}`,
'Content-Type': 'application/json',
},
}
);
return response.data;
}, {
connection: { host: 'localhost', port: 6379 },
concurrency: 10, // adjust based on your rate limit
});
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err.message);
// connect your alerting system here (Slack, PagerDuty, whatever you use)
});
Step 3: Queue Messages with Calculated Delay
// scheduler.js
import { Queue } from 'bullmq';
import { db } from './db.js'; // your database client
const queue = new Queue('whatsapp-scheduled', {
connection: { host: 'localhost', port: 6379 },
});
async function scheduleBlast(templateName, languageCode, targetHour = 10) {
const subscribers = await db.query(`
SELECT id, phone, timezone,
(NOW()::DATE + $1::TIME) AT TIME ZONE timezone AT TIME ZONE 'UTC' AS send_at_utc
FROM subscribers
WHERE opted_in = TRUE
`, [`${targetHour}:00:00`]);
for (const user of subscribers.rows) {
const delay = new Date(user.send_at_utc).getTime() - Date.now();
if (delay < 0) {
console.warn(`Past time for ${user.phone}, skipping.`);
continue;
}
await queue.add(
'send-message',
{ phone: user.phone, templateName, languageCode },
{
delay,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
jobId: `msg-${user.id}-${Date.now()}`, // prevents duplicates
}
);
}
console.log(`${subscribers.rows.length} messages queued.`);
}
scheduleBlast('promo_july', 'en_US', 10);
Step 4: Conversation Costs in 2026 — Don't Get Surprised
Since Meta migrated to its per-conversation billing model, marketing messages (which include virtually all scheduled promotional sends) have rates that vary by country. In Mexico, the cost per marketing conversation is around $0.0588 USD as of mid-2026 — always verify in Meta's official documentation because these prices change quarterly.
What you need to understand:
- One conversation = a 24-hour window from your first outbound message. If you send 5 messages within that window, you pay for one conversation.
- Service conversations (when the user writes first) have a different and generally lower rate.
- Utility messages (order confirmations, shipping updates) have their own category and pricing.
Practical implication: if you have a list of 50,000 subscribers and send a weekly blast, you're looking at approximately $2,940 USD per send in Mexico alone. Calculate your ROI before hitting that button.
Step 5: Error Handling in Production
The most frequent errors you'll see in production and how to respond to each:
| Error Code | Cause | Action |
|---|---|---|
131026 |
Number not on WhatsApp | Mark as invalid in DB, do not retry |
131047 |
24h window closed for unapproved template | Check template category |
130429 |
Rate limit reached | Exponential backoff, reduce concurrency |
132001 |
Template not approved or suspended | Review in Meta Business Manager |
100 |
Invalid or expired token | Renew token, alert the team |
Never retry blindly. Every unnecessary retry has a cost and can lead Meta to suspend your number if you accumulate too many errors in a short period.
Production Warnings Nobody Tells You
Do not use the same number for testing and production. Meta has a long memory with numbers that generate massive errors during testing.
Get your templates approved at least 5 days in advance. Meta's review process can take between 24 hours and several days if there is manual review, and they don't notify you if there's a problem — you have to go check yourself.
Respect opt-outs immediately. If a user sends "STOP" or any variation, you must update your database before the next send cycle. Meta's policies require this, and non-compliance can cost you your number.
Monitor your Quality Rating in Meta Business Manager. If it drops to "Low," Meta limits your send capacity. If it reaches suspension, recovering the number can take weeks.
Final Result
With this architecture you have messages arriving exactly at 10 AM in each user's timezone, with smart retries, cost control, and logs of everything that happens. No setTimeout, no cron jobs that get lost on a restart, no numbers banned by carelessness.
This is the minimum you should have running before your first mass blast in 2026.