A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing
There is no email address anywhere in this system. Not on the user table, not in the signup form, not as an optional field.
That is not an oversight, and working through why it is correct changed how I think about defaults.
Why No Email
Indonesian consumers use WhatsApp. Not “also use” — it is the default channel for talking to a business, arranging a delivery, and asking whether the shoes are ready.
Email is for receipts from companies you do not know and airline confirmations. A password reset sent by email to this customer base goes to an address checked weekly, if the person remembers which one they used.
So the login identity is a phone number:
const AuthFinder = withAuthFinder(hash, {
uids: ['phone'],
passwordColumnName: 'password'
})uids: ['phone']. That is the whole change, and everything follows from it: the phone validator becomes the identity format, the password reset goes over WhatsApp, and there is no “which email did I sign up with” support conversation because there is no email.
The general point: the notification channel is a product decision about your users, not a technical default. Email is the default because it is the default in the places most frameworks were written, not because it is the best channel for every audience.
The Provider Choice
WhatsApp has an official Business API. It also has a set of onboarding requirements: a Meta Business account, business verification, a phone number registered to the platform, and — the one that matters — an approved message template for every message you send. Change the wording of a reminder and you resubmit for approval.
That is proportionate for a company sending millions of messages. It is not proportionate for a shoe cleaning shop sending a few dozen.
Fonnte is an Indonesian service that wraps a WhatsApp session behind an HTTP API. You connect a phone number, you POST a target and a message.
The trade, stated plainly: it is unofficial. It depends on a WhatsApp session that can disconnect, and it has no SLA worth the name. I would not put a large business’s password resets on it.
For this shop, weighed against the Business API onboarding for a handful of messages a day, it was the right call. It is also the most fragile external dependency in the system, and the first thing I would replace if the business grew.
The Service
private async sendMessage(target: string, message: string): Promise<void> {
let response: Response
try {
response = await fetch('https://api.fonnte.com/send', {
method: 'POST',
headers: {
'Authorization': env.get('FONNTE_API_KEY').release(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ target, message, preview: false }),
})
} catch {
throw new Error('Tidak dapat terhubung ke layanan WhatsApp.')
}
const payload = (await response.json()) as FonnteResponse
if (!response.ok || !payload.status) {
throw new Error('Gagal mengirim pesan WhatsApp.')
}
}No SDK. fetch against one endpoint. For an API this small, a dependency would be more surface area than the code it replaces.
HTTP 200 Means Nothing Here
The gotcha, flagged in the source because it is genuinely surprising:
/**
* Fonnte answers with HTTP 200 even when it rejects a message, so the
* response payload's own `status` flag has to be checked as well.
*/if (!response.ok || !payload.status) {A rejected message — invalid number, disconnected device, no quota — comes back 200 OK with status: false in the body.
Check only response.ok and every failure looks like a success. Password reset links vanish. Customers are never told their shoes are ready. Nothing logs an error, because as far as the HTTP layer is concerned nothing went wrong.
Both checks. response.ok catches transport failures, payload.status catches application failures, and the two are genuinely independent.
This is not unique to Fonnte. Plenty of APIs — especially ones that started as internal tools — return 200 with an error body. The habit worth forming: when integrating anything, send a deliberately invalid request once and look at what comes back. Two minutes, and it tells you whether the status code is load-bearing.
The two failure paths are distinguished on purpose:
catcharoundfetch— could not reach the service. Network, DNS, timeout.- The status check — reached it, it refused.
Different messages, because they need different responses. One is retry, the other is fix something.
The Messages
Four of them, each a method:
async sendPasswordResetLink(target: string, resetUrl: string): Promise<void> {
await this.sendMessage(
target,
[
'Umima.Clean menerima permintaan reset password untuk akun Anda.',
'Klik link berikut untuk membuat password baru:',
resetUrl,
'Abaikan pesan ini jika Anda tidak meminta reset password.',
].join('\n\n')
)
}The array-joined-with-\n\n pattern is worth stealing. WhatsApp renders double newlines as paragraph breaks, and building the message as lines keeps it readable in the source instead of as one template literal with escapes in it.
Every message ends with an opt-out line. “Ignore this if you did not request it.” Someone receiving an unexpected reset link should be told immediately that ignoring it is safe — otherwise the message reads as a compromise notification rather than a routine one.
The other three:
/**
* Reminds a customer that an order is still waiting to be paid for.
*
* Sent by hand from the counter rather than on a timer: staff are the ones
* who can see that a customer simply forgot, as opposed to one who is
* deciding, and an automatic nag to the second group costs goodwill.
*/
async sendPaymentReminder(target: string, orderNumber: string, amount: string)/**
* Tells a walk-in customer their shoes are washed and waiting at the shop.
*
* Only counter orders get this. An order that is being delivered needs no
* message — it turns up at the door on its own.
*/
async sendReadyForCollection(target: string, orderNumber: string)async sendVerificationLink(target: string, verificationUrl: string)The sendReadyForCollection restriction is a small thing that would be an obvious bug in the other direction: telling someone to come and collect shoes that are in the back of a van, on their way to their house.
Signed URLs
Both link messages carry a signed URL rather than a token stored in a table:
const resetUrl = signedUrlFor(
'password_reset.edit',
{},
{
qs: { phone: user.phone },
expiresIn: '15m',
prefixUrl: appUrl
}
)Adonis signs the URL with the app key. The signature covers the path and the query string, so tampering with phone invalidates it.
Three things this buys.
No token table. No rows to create, look up, expire, or clean up.
Expiry is in the URL. 15m, enforced by the framework on the way in. Nothing to check, nothing to sweep.
The link carries its own identity. This is the subtle one. A reset link that only says “reset a password” and relies on the session to know whose password is a real vulnerability on a shared machine — open somebody else’s link while signed in as yourself and you change the wrong account. Putting the phone in the signed query string means the link is bound to the account it was issued for, and the signature makes that binding tamper-proof.
The same reasoning applies to phone-change verification: the link carries the account it was issued for.
Enumeration, Handled Quietly
/**
* Unknown phone numbers are silently ignored so the response cannot be used
* to discover which numbers have an account. A deactivated account is
* treated the same way — sending it a working reset link would hand back the
* door that deactivating it closed.
*/
async requestPasswordReset(data: ForgotPasswordData): Promise<void> {
const user = await User.findBy('phone', data.phone)
if (!user || !user.isActive) {
return
}
// ... send the link
}Return without sending. The page shows the same “check your WhatsApp” message either way.
Why it matters more here than with email. Phone numbers are sequential and guessable in a way email addresses are not. A form that says “no account with that number” is an oracle for testing a whole prefix range, and the answers are worth something — which numbers belong to customers of this shop.
The deactivated-account case is the one that is easy to miss. A deactivated staff member requesting a reset would otherwise receive a working link, set a new password, and walk back in through a door that was deliberately closed. Same silent return.
The rate limiter is the other half of that defence, and it is the strictest in the app:
export const forgotPasswordLimiter = limiter.define('forgot-password', (ctx) => {
return limiter
.allowRequests(1)
.every('15 minutes')
.blockFor('15 minute')
.usingKey(`forgot-password:${ctx.request.ip()}`)
})One request per fifteen minutes per IP. Aggressive, and correct for two reasons: enumeration becomes impractical, and — because each send costs money and consumes a real WhatsApp session’s quota — someone hammering the form is spending the shop’s resources.
Sent by Hand, on Purpose
The design decision I like most in this part is a thing that does not exist: there is no scheduler.
/**
* Both are triggered by hand from the counter rather than fired on a timer.
* Staff can see the difference between a customer who forgot and one who is
* still thinking about it, and between shoes that are ready and shoes that are
* ready but the owner already said they are travelling. An automatic message
* cannot, and gets it wrong in public.
*
* Every send is written to the order's audit trail, so "we already chased
* them" is something the record answers rather than something two staff
* members disagree about — and so the same customer is not messaged twice.
*/The automated version is easier to build. Cron, find orders awaiting payment for over 24 hours, send. It is also worse to receive: a customer who is deciding whether to go ahead gets nagged, and a customer who mentioned they are away for a week gets told their shoes are ready three times.
Staff know things the database does not. Letting them decide is not laziness about automation — it is the automation being wrong.
Every send is recorded on the order, which is what makes it work in practice. Two staff members at the same counter, and the record answers “have we already chased them?” instead of them guessing.
Sending is guarded by state:
if (order.status !== OrderStatus.AWAITING_PAYMENT) {
throw new vineErrors.E_VALIDATION_ERROR([
{ field: 'status', message: 'Pesanan ini tidak sedang menunggu pembayaran.' }
])
}A validation error, so it renders inline rather than as a 500. The button is disabled in the UI too — the check is the guarantee, the disabled button is the courtesy.
The ready notice has the equivalent guard: only orders sitting on the shelf, never one out for delivery.
Failure Behaviour
Sending throws. Nothing catches it above AuthService, which means a failed WhatsApp send fails the request.
For a password reset that is right: the user needs to know the link is not coming, not sit waiting for a message that will never arrive.
For a manual reminder it is also right — the staff member is standing there and can try again.
There is no queue and no retry. A failure is immediate and visible rather than swallowed into a dead letter table nobody reads. In a system with one process and no worker (Part 1), that is the honest design: an operation that cannot be retried in the background should tell the person who triggered it.
The cost is that a slow Fonnte response is a slow request. Acceptable at this volume, and it would not be at ten times the traffic.
What I Would Change
Fonnte itself, if the shop grows. The Business API onboarding is proportionate once the message volume is real. This is the dependency I would replace first.
No delivery confirmation. Fonnte reports acceptance, not delivery. “The message was accepted” and “the customer read it” are different facts, and the system only knows the first.
No retry on transient failure. A momentary network blip fails a password reset that would have worked a second later. A single retry with a short backoff would cover most of those without needing a queue.
Templates are string arrays in a service. Fine for four messages. At fifteen, they belong in a template file where they can be edited without a deploy — the wording of a customer-facing message is the sort of thing an owner should be able to change.
What I would keep: the phone as identity, signed URLs carrying their own account, and messages sent by a person who can see the situation. The first two are technical decisions that removed whole categories of work. The third is a product decision that a lot of systems get wrong by defaulting to automation because automation is easier to build.
Next: Part 10 — ExcelJS and Exports That Are Actually Usable, where a timezone bug puts every date in the file one day early, consistently enough that nobody notices.