Sending messages
One send path, one retry policy, one throughput gate — MessageClient, MM Lite routing, and the 24-hour window.
The messages module of @genuka/whatsapp: one send path, one retry policy, one throughput gate.
import { MetaTransport } from "@genuka/whatsapp";
import { messages } from "@genuka/whatsapp";
import { MessageClient } from "@genuka/whatsapp/messages/send";
const client = new MessageClient({
transport: new MetaTransport({ accessToken }),
phoneNumberId: "1234567890",
});
const result = await client.send(messages.text("+237699001122", "Bonjour"));
result.messageId; // "wamid.HBg…"The same client works against Genuka WA instead of Meta — swap MetaTransport for
GenukaTransport and nothing else changes. Both transports project failures onto the same
WhatsAppError / errorClass taxonomy, which is what makes the retry policy below transport-
independent.
messages/send.tsis this module's entry point. Once the package root re-exports it, everything below is reachable from@genuka/whatsappdirectly; the deep path stays valid either way.
1. MessageClient.send()
send(message: OutboundMessage, context?: SendContext): Promise<MessageSendResult>message is whatever messages.* built — text, media, interactive, template. It is posted
verbatim: nothing is added, nothing is stripped, so biz_opaque_callback_data set by the
builder reaches Meta unchanged and comes back on the status webhook.
MessageSendResult is Meta's response plus three fields:
| Field | Meaning |
|---|---|
messageId | the wamid, hoisted out of messages[0] |
endpoint | "messages" or "marketing_messages" — which edge accepted it, after any fallback |
fellBackFromMarketing | true when MM Lite was tried, was unavailable, and /messages took over |
Failures reach you as WhatsAppError, built by the transport, not re-wrapped. Branch on
errorClass, never on the message text:
try {
await client.send(message);
} catch (error) {
if (error instanceof WhatsAppError && error.errorClass === "needs_template") {
// 131047: the 24h window closed. Re-engage with an approved template.
}
}| Situation | Meta code | errorClass |
|---|---|---|
| service window closed | 131047 | needs_template |
| stale or unreachable media | 131052 | media |
| throughput / rate limit | 130429, HTTP 429 | retryable |
| template paused, missing, malformed | 132xxx | template |
| recipient not on WhatsApp, opted out | 131026, 131050 | recipient_permanent |
| per-user marketing cap | 131049 | recipient_throttled |
2. MM Lite routing
Marketing Messages Lite is a second send endpoint, not a second API: same WABA, same number, same approved templates, same payload. Only the URL differs.
POST /{PHONE_NUMBER_ID}/messages ← everything
POST /{PHONE_NUMBER_ID}/marketing_messages ← MARKETING templates onlyRouting is driven by the template category, which the caller supplies:
await client.send(promo, { category: "MARKETING" }); // → /marketing_messages
await client.send(receipt, { category: "UTILITY" }); // → /messages
await client.send(otp, { category: "AUTHENTICATION" }); // → /messages
await client.send(promo); // → /messages (no category, no guess)Why the caller supplies the category
The category belongs to the approved template, not to the send payload — it is simply not present
in OutboundMessage, and Graph never echoes it. Inferring it from the template name is the one
shortcut worth refusing: promo_confirmation_v2 is a name, not a contract, and a mis-inferred
category puts an OTP on a channel that is allowed to hold it back for hours.
Pass disableMarketingRoute: true to force the classic endpoint for a send whose timing must not
be optimised (an A/B against MM Lite, a time-critical promo drop).
⚠️ accepted means queued, not sent
This is the single most important sentence on this page.
On /messages, Meta accepts the payload and hands it to the delivery pipeline. On
/marketing_messages, Meta may deliberately delay the message to hit a moment the user is
likely to open it, and may deliberately drop it against the per-user marketing cap — a
legitimate failed, not a bug (131049, 131050).
So message_status: "accepted" is an acknowledgement of receipt by Meta and nothing more. The
only source of truth for what happened to a message is the messages status webhook:
sent → delivered → read, or failed. Campaign UIs must say "envoi en cours", and every
counter must be fed by webhooks, never by send responses.
Silent fallback
MM Lite is not enabled on every account or in every country, and Meta exposes no capability flag
to check beforehand — you find out by posting. When the marketing endpoint refuses for an
availability reason, the send is retried once on /messages and fellBackFromMarketing: true is
set on the result.
"Availability reason" means a 4xx that classifies as config (capability/permission missing) or
unknown (unsupported POST on a non-existent edge). Deliberately excluded:
- message-level classes (
template,validation,needs_template,recipient_*,media) — the same payload would be rejected identically on/messages, so falling back would only double the failure rate; - credential failures (codes 0 and 190) — the second call would fail the same way, with
fellBackFromMarketinghiding the real cause; - 5xx and network failures — those are
retryableand belong to the retry policy, not to endpoint selection.
Worth persisting fellBackFromMarketing on the message row: it is the only trace that this
account never had MM Lite in the first place.
3. Retry policy
decideRetry is a pure function of errorClass. No timers, no network, no clock — which is why
it can be tested exhaustively and swapped wholesale.
import { decideRetry, runWithRetry, shouldReschedule } from "@genuka/whatsapp/messages/send";
const outcome = decideRetry(error, attempt); // attempt is 1-based, the one that just failed
// { action: "retry", delayMs, invalidateMedia } | { action: "fail", reason } | { action: "reschedule" }errorClass | Behaviour |
|---|---|
retryable | 3 attempts, exponential backoff + jitter (500 ms → 1 s → …, capped at 30 s) |
media | invalidate the cached media_id, then exactly one more attempt |
recipient_throttled | reschedule — drop from this campaign run, schedule into a later one |
recipient_permanent, template, config, validation, needs_template, unknown | never retried |
unknown is treated as permanent on purpose: the safe failure mode for a code we do not
understand is to stop and get it classified in errors.ts.
Jitter
delay = min(500 × 2^(attempt-1), 30_000), then a uniform draw over the top
1 - jitterRatio of it. At the default jitterRatio: 0.5, a nominal 2 s wait becomes a draw in
[1 s, 2 s] — enough spread to break the thundering herd of a fleet of workers that all took a
429 in the same millisecond, without collapsing the backoff to nothing.
The runner
const result = await runWithRetry((attempt) => client.send(message), {
media: { resolver, assetId, phoneNumberId }, // enables invalidate-then-retry on 131052
onRetry: ({ attempt, delayMs }) => log.warn({ attempt, delayMs }),
});sleepandrandomare injectable, so tests run in microseconds and produce the same numbers every time. The defaults are a real timer andMath.random.- On a
mediafailure the resolver is invalidated before the wait, so a worker that dies mid-backoff has still dropped the poisoned id. - Anything that is not a
WhatsAppErrorpropagates untouched: aTypeErrorin your own code is a bug, and retrying it three times only makes the stack trace harder to find. - The last failure is rethrown with
errorClassintact — includingrecipient_throttled, whichshouldReschedule(error)identifies for the campaign runner.
Re-resolving the media_id between attempts is the media module's job: runWithRetry invalidates the
cache, the operation must ask the resolver again on its next call.
4. Rate limiting
Meta caps sends per business phone number, per second. Exceeding it does not queue — it fails with 130429.
| Number | Cap |
|---|---|
| default (registered Cloud API number) | 80 mps |
upgraded (throughput.level = HIGH_THROUGHPUT) | 1 000 mps |
coexistence (platform_type = SMB_APP) | 20 mps, fixed |
import { RateLimiter, resolveThroughput, estimateDuration } from "@genuka/whatsapp/messages/send";
const mps = resolveThroughput({
platformType: connection.platformType, // "SMB_APP" wins over everything
throughputLevel: connection.throughputLevel,
});
const limiter = new RateLimiter({ mps });
for (const recipient of recipients) {
await limiter.run(() => client.send(build(recipient), { category: "MARKETING" }));
}Coexistence overrides everything. An SMB_APP number reports a throughput level like any
other, and that level is meaningless: the 20 mps ceiling comes from the WhatsApp Business app
sharing the number, not from the tier. Reading the level first is the bug resolveThroughput
exists to prevent.
Sliding, not fixed
A fixed-window counter allows twice the cap across a boundary — 80 grants at 12:00:00.999 and 80
more at 12:00:01.001 is 160 in two milliseconds, and Meta counts all 160. RateLimiter
remembers when the last mps grants happened and refuses a slot until the oldest has aged out of
the trailing second.
It is single-process state. A campaign sharded over several workers must divide the cap between them, or move the counter behind shared storage: Meta enforces the ceiling on the number, not on the process.
Announcing the duration before launch
estimateDuration(100_000, 20); // 4_999_950 ms ≈ 83 minutes
limiter.estimateDuration(100_000); // same, using the limiter's own cap(count - 1) / mps seconds: the first burst of mps leaves immediately, so the figure is the
wait until the last message goes out. It is a floor — it assumes the loop keeps the pipe
saturated and that Meta never throttles — so present it as "at least", never as an ETA.
A campaign of 100 000 messages on a coexistence number takes about an hour and a half. The operator has to be told that before pressing the button.
5. Read receipts and typing
await client.markAsRead(inboundWamid);
await client.typing(inboundWamid); // marks as read AND shows the bubbleExposed over HTTP as:
POST /api/v1/messages/{id}/read
{ "connectionId": "…", "typing": true }{id} is the inbound wamid from the messages webhook. It can contain / and =, so
URL-encode it into the path.
Three rules Meta imposes, and one consequence each:
- Typing only exists coupled to a read receipt. There is no way to type spontaneously; the call marks a specific inbound message as read and shows the bubble in the same request.
- The bubble auto-dismisses after
limits.CONVERSATION.typingIndicatorSeconds(25 s), or as soon as the reply is sent, whichever comes first. There is no cancel and no refresh — call it when the reply is actually being produced. A bubble that expires with nothing behind it reads worse to the customer than no bubble at all. - A message can only be marked read for
limits.CONVERSATION.markReadWithinDays(30 days) after it arrived. Past that Meta refuses and the blue ticks are lost for good, so a read-receipt backlog is worth draining rather than queueing indefinitely.
Marking one message read also marks every earlier message of that conversation read: catching up on a thread is one call on its newest inbound message, not one per message.