For business applications in Indonesia, email notifications have a fundamental problem: a lot of them are never opened. Shop customers, warehouse staff, and cooperative managers generally do not watch an inbox. WhatsApp is a different story, where a message is almost certainly read within minutes.
The consequence is that โnotify the customerโ here nearly always means WhatsApp rather than email. This is about building that in a way that does not fall over.
Two Routes, Two Sets of Consequences
The official WhatsApp Cloud API from Meta. Legal and stable, but it requires business verification, a dedicated number that is not used in the regular WhatsApp app, and message templates approved in advance. For business-initiated messages outside the 24-hour window, only templates may be sent.
Unofficial third-party gateways such as Fonnte and similar services. Far faster to set up, no template approval, and you can use an ordinary number. The trade-off is that the number risks being blocked by Meta if the sending pattern looks like spam, and the service can change at any time outside your control.
For internal systems and operational notifications at reasonable volume, the second route is often enough and far cheaper. For applications messaging thousands of strangers, the official route is safer over time.
Whichever you pick, wrap it behind your own interface. One day you may switch providers, and nobody wants to hunt down API calls scattered across fifty files.
// src/notifications/whatsapp.js
export interface WhatsAppProvider {
send(to: string, message: string): Promise<{ id: string }>;
}
// A Fonnte implementation, a Cloud API one, or a mock for testing
// all satisfy the same interface.
Do Not Send Inside the Request
The first temptation is calling the WhatsApp API directly inside an HTTP handler. Do not.
Sending can take several seconds, can fail on the network, and can hit a rate limit. If that happens inside the order creation request, the customer sees an error even though the order was in fact created.
Split it into two steps. Persist the message with a PENDING status, then let a separate worker do the sending.
await prisma.waMessage.create({
data: {
to: normalizePhone(customer.phone),
body: renderTemplate('order_paid', { name: customer.name, id: order.id }),
status: 'PENDING',
attempts: 0,
},
});
This buys you three things at once: the request stays fast, messages are not lost if the process dies, and you get a complete history of what was sent to whom.
Normalise Numbers First
Phone numbers arriving from a form will be messy. People write 0895..., +62 895..., 62895..., with hyphens, with spaces, sometimes with the letter O standing in for a zero.
Gateways generally expect the international format without a plus sign. Normalise once, in one place:
export function normalizePhone(raw) {
const digits = String(raw).replace(/\D/g, '');
if (digits.startsWith('62')) return digits;
if (digits.startsWith('0')) return '62' + digits.slice(1);
if (digits.startsWith('8')) return '62' + digits;
return null; // let the caller decide
}
Return null for anything unrecognised rather than guessing. A wrongly guessed number means customer Aโs message reaches a stranger, and that is a privacy incident, not merely a bug.
Store the normalised version in the database, not the raw input. Otherwise you will end up with three customer rows that are the same person.
Queue, Retry, and Limits
The sending worker needs three behaviours.
Rate limit yourself. Sending five hundred messages in a minute is the fastest way to get a number blocked. Put a gap between sends, and a longer gap for bulk runs.
Retry with backoff. Network failures are usually temporary. Try again after one minute, then five, then thirty. But distinguish the kind of failure.
Stop on permanent failures. A number not registered on WhatsApp will not become registered by trying a thousand more times. Mark it FAILED and stop.
const PERMANENT = ['invalid_number', 'not_registered', 'blocked'];
async function deliver(message) {
try {
const res = await provider.send(message.to, message.body);
await markSent(message.id, res.id);
} catch (err) {
const permanent = PERMANENT.includes(err.code);
const exhausted = message.attempts + 1 >= 5;
await prisma.waMessage.update({
where: { id: message.id },
data: {
status: permanent || exhausted ? 'FAILED' : 'PENDING',
attempts: { increment: 1 },
lastError: err.code ?? String(err).slice(0, 200),
nextAttemptAt: permanent ? null : backoff(message.attempts),
},
});
}
}
Storing lastError looks trivial, but it is what lets you answer โwhy did Mrs Siti not get her notificationโ without guessing.
The Message Itself Decides Whether It Gets Read
A WhatsApp notification appears in the same space as family conversations. A message that is too long or reads like a robot will be ignored, or worse, blocked.
A few things make a large difference:
- Address the recipient by name at the start
- Put the most important information on the first line, since that is what appears in the notification without opening the app
- One message, one purpose; do not combine an invoice with a promotion
- Include a way to follow up, such as an order number or a tracking link
- Close with a clear business identity
A useful message reads like this:
Hi Andi, we have received payment for order #INV-2291.
Total: Rp 450,000
Estimated delivery: 2 working days
Track: https://example.id/t/INV-2291
Thank you, Toko Sejahtera
Keep templates in the database or a config file rather than scattered as strings through the code. At some point the business owner will want the wording changed, and that should not require a deploy.
Respect the Right to Opt Out
This is often skipped in internal applications, and it matters. Give customers a way to stop receiving marketing messages, and honour it.
Separate the two kinds of message. Transactional ones such as payment confirmations or delivery updates are generally expected by the recipient. Promotional ones require consent and must be stoppable.
A single marketingOptOut column on the customer table, plus a check in the sending worker, is enough to start. The important part is not mixing the two in one channel, because a single spam complaint can get the business number blocked and take every transactional notification down with it.
Watch the Status Webhook
Most gateways can send delivery status back: sent, delivered, read, or failed. Use that to complete your records.
The most useful signal is not โreadโ, it is the failure pattern. If many messages suddenly fail within a short window, the sending number is probably in trouble, and you want to know that before a customer tells you.
Closing
WhatsApp notifications feel trivial until they are used in earnest. What makes them reliable is not the API but everything around it: normalised numbers, queued messages, failures that are properly distinguished, a stored history, and message content that respects the personal space it lands in.