Genuka WA docs

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.ts is this module's entry point. Once the package root re-exports it, everything below is reachable from @genuka/whatsapp directly; 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:

FieldMeaning
messageIdthe wamid, hoisted out of messages[0]
endpoint"messages" or "marketing_messages" — which edge accepted it, after any fallback
fellBackFromMarketingtrue 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.
  }
}
SituationMeta codeerrorClass
service window closed131047needs_template
stale or unreachable media131052media
throughput / rate limit130429, HTTP 429retryable
template paused, missing, malformed132xxxtemplate
recipient not on WhatsApp, opted out131026, 131050recipient_permanent
per-user marketing cap131049recipient_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 only

Routing 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 fellBackFromMarketing hiding the real cause;
  • 5xx and network failures — those are retryable and 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" }
errorClassBehaviour
retryable3 attempts, exponential backoff + jitter (500 ms → 1 s → …, capped at 30 s)
mediainvalidate the cached media_id, then exactly one more attempt
recipient_throttledreschedule — drop from this campaign run, schedule into a later one
recipient_permanent, template, config, validation, needs_template, unknownnever 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 }),
});
  • sleep and random are injectable, so tests run in microseconds and produce the same numbers every time. The defaults are a real timer and Math.random.
  • On a media failure 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 WhatsAppError propagates untouched: a TypeError in 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 errorClass intact — including recipient_throttled, which shouldReschedule(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.

NumberCap
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 bubble

Exposed 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.

On this page