Genuka WA docs

Management

Business profile, conversational components, blocklist, QR codes, number health and analytics.

Everything a merchant configures once and sees immediately, plus the two reads an operator lives on. Import from @genuka/whatsapp/management.

import { MetaTransport } from "@genuka/whatsapp";
import { getBusinessProfile } from "@genuka/whatsapp/management";

const transport = new MetaTransport({ accessToken });
const profile = await getBusinessProfile(transport, phoneNumberId);

Every function takes a Transport first, so the same call works against Meta from our servers (MetaTransport) and against /api/v1/* from a customer's code (GenukaTransport). The pure halves — validation, normalization, detectHealthAlerts, aggregateByDay — need no transport at all and are where the tests live.

ConcernModuleGenuka WA route
Business profilemanagement/profile.tsGET/PATCH /api/v1/profile
Welcome message, ice breakers, commandsmanagement/automation.tsGET/PATCH /api/v1/conversation-settings
Blocked usersmanagement/blocklist.tsGET/POST/DELETE /api/v1/blocked-users
QR codes and short linksmanagement/qr.ts/api/v1/qr-codes, /api/v1/qr-codes/{code}
Number healthmanagement/health.tsGET /api/v1/numbers, GET /api/v1/numbers/{id}/health
Messaging / cost analyticsmanagement/analytics.tsGET /api/v1/analytics?kind=…

Business profile

GET/POST /{PHONE_NUMBER_ID}/whatsapp_business_profile.

await updateBusinessProfile(transport, phoneNumberId, {
  about: "Ouvert du lundi au samedi, 8h–19h",
  websites: ["https://boutique.example"],
  vertical: "RETAIL",
});
FieldLimit
about139 characters
address256
description512
email128
websites2 max, 256 characters each, absolute URL with a scheme
verticalclosed enum — limits.BUSINESS_VERTICALS

Why this is wrapped rather than called raw:

  • Meta drops what it does not like, silently. A third website, an over-long about, a vertical outside the enum: no field-level error, HTTP 200, and the merchant finds the URL missing later. Everything is checked before the request leaves.
  • The POST is partial, but a key sent as null clears the field. buildBusinessProfileUpdate only emits the keys you actually named. /api/v1/profile PATCH does the same.
  • The write re-reads. Meta normalizes (lowercases the email, strips trailing slashes), so echoing back the request would show you something that is not true.

The profile picture takes a handle from the resumable upload API — not a media id, not a URL.


Conversational automation

POST /{PHONE_NUMBER_ID}/conversational_automation to write, GET /{PHONE_NUMBER_ID}?fields=conversational_automation to read. The read does not live on the edge; calling GET there is a 400.

const current = await getConversationalAutomation(transport, phoneNumberId);
await setConversationalAutomation(
  transport,
  phoneNumberId,
  mergeConversationalAutomation(current, { iceBreakers: ["Voir le menu", "Suivre ma commande"] }),
);
ComponentLimit
Ice breakers4 max, 80 characters each
Commands30 max; name ≤ 32 characters, description ≤ 256
Welcome messageboolean toggle

Three things to know:

  1. The POST replaces the whole object. Sending only prompts deletes the commands, and Meta answers 200. Always mergeConversationalAutomation onto a fresh read — that is what /api/v1/conversation-settings PATCH does.
  2. welcomeMessageEnabled sends nothing on its own. It makes WhatsApp emit a request_welcome webhook when a customer opens a brand-new chat; replying is the webhook pipeline's job. A merchant who enables the toggle without a reply wired up has enabled nothing.
  3. Ice breakers only appear in a chat with no history. A merchant testing on their own number, which already has a conversation, will not see them and will report a bug.

validateConversationalAutomation(update) returns every problem as { field, message } instead of throwing on the first — this backs a settings form, and fixing four mistakes one round-trip at a time is a bad afternoon. The messages describe what the customer would see, not what the schema wants:

commands[0].name: must not start with "/" — WhatsApp adds it, so "/help" would show as "//help".
iceBreakers[1]:   is 93 characters; WhatsApp cuts ice breakers at 80 and does not wrap, so your
                  customer would read "Bonjour ! Pour suivre votre commande, indiquez…".

buildConversationalAutomationUpdate throws the first issue as a ValidationError for library callers who want the classic behaviour.


Blocked users

POST / DELETE / GET on /{PHONE_NUMBER_ID}/block_users.

const outcome = await blockUsers(transport, phoneNumberId, ["+237699001122", "+237690000000"]);
// { action: "block", requested: 2, succeeded: [...], failed: [{ number, code, message, hint }] }
ConstraintValue
Numbers per request1 000 (limits.BLOCKLIST.perRequestMax)
Total blocklist size64 000 (limits.BLOCKLIST.totalMax)
Eligibilitythe user must have written to you within 24 h

Also refused: WhatsApp Business accounts, and your own number.

Partial success is the normal case. Meta returns added_users / removed_users alongside failed_users and still answers 200. summarizeBlockResponse splits them faithfully, and a number Meta mentions in neither list is reported as failed, never assumed blocked — a silent disappearance in a moderation tool is worse than an error.

Failures keep Meta's own wording verbatim (support tickets quote it) and add a hint for the two codes that actually recur:

CodeHint
131047no inbound message in the last 24 h — the conversation must still be open
139101this number cannot be blocked, or the 64 000 cap is reached

The 24-hour rule cannot be checked before the call: we do not know the inbound history of a number we have never seen.

/api/v1/blocked-users maps the outcome onto the status code — 200 all through, 207 partial, 422 nothing through — and itemises either way.


/{PHONE_NUMBER_ID}/message_qrdls. A code is a permanent https://wa.me/message/{code} link that opens WhatsApp with a message already typed but not sent.

const qr = await createQrCode(transport, phoneNumberId, {
  prefilledMessage: "Je veux voir le menu",
  imageFormat: "PNG",
});
// { code, prefilledMessage, deepLinkUrl: "https://wa.me/message/…", qrImageUrl }
ConstraintValue
prefilled_message140 characters (limits.QR_CODE.prefilledMessageMax)
Codes per number2 000 (limits.QR_CODE.perNumberMax)
Image formatsSVG, PNG

⚠️ Meta publishes no analytics on QR codes

No scan count, no click count, no referrer — not "not yet", not "on request". It is a privacy decision, so there is nothing to expose later and nothing to ask support for. A customer who wants to know which flyer worked must be sent through a redirect we host, which counts the hit and then forwards to the wa.me link. Do not build a "QR performance" screen on top of this module: there is no data behind it.

updateQrCode changes the message behind an existing code without changing the code, so printed assets keep working — that is the whole point of the endpoint. deleteQrCode has no undo and the code is never re-issued: every printed asset pointing at it is dead.

qrImageUrl is a CDN URL that expires. Download the bytes; never embed that URL in a printed asset.


Number health

GET /{PHONE_NUMBER_ID}?fields=… (NUMBER_HEALTH_FIELDS) or GET /{WABA_ID}/phone_numbers.

const health = await getNumberHealth(transport, phoneNumberId);
// { qualityRating, messagingLimitTier, throughputLevel, platformType, status,
//   coexistence, messagesPerSecond }

messagesPerSecond is derived, and coexistence wins over everything: a number shared with the WhatsApp Business app is pinned at 20 mps whatever its throughput level says. That is the difference between a 100 000-message campaign taking 2 minutes and taking 83 — get it wrong and you promise a delivery window you cannot hold.

detectHealthAlerts(previous, current)

Pure, and the actual product here: reading the fields is cheap, comparing them is what warns you. previous is loose enough (string | null) to accept a Prisma WhatsappConnection row directly.

AlertSeverity
quality_dropped to YELLOWwarning
quality_dropped to REDcritical
quality_recoveredinfo
messaging_limit_changed, downgradewarning
messaging_limit_changed, upgradeinfo
throughput_changed, HIGH → lowerwarning
status_changed to FLAGGED / RATE_LIMITEDwarning
status_changed to RESTRICTED / BANNEDcritical

Rules that are not obvious:

  • No previous snapshot means no alert. The first read is a baseline, not an event; alerting on it would fire once per new connection, forever.
  • UNKNOWN quality is not on the scale. Meta reports it for any number with too little volume to score, so a shop that closes for the weekend flips GREEN → UNKNOWN → GREEN. Treating that as a drop pages you every Monday for a healthy number.
  • A field Meta stopped returning is not a change. That is our blind spot, not their signal.
  • Never diff Meta's status against the Genuka status column. Ours is a lifecycle value (connected, offboarded, …), Meta's is CONNECTED / FLAGGED / …; diffing the two vocabularies raises an alert on every read. /api/v1/numbers/{id}/health keeps them apart as metaStatus and genukaStatus.

The detector is transport-free on purpose: it belongs both in this on-demand route and in the phone_number_quality_update webhook handler, which is where a change is learned without anyone asking.


Analytics

GET /{WABA_ID}?fields=analytics|conversation_analytics|pricing_analytics behind Graph's field-expansion syntax.

const result = await fetchAnalytics(transport, wabaId, "pricing", {
  start: new Date("2026-07-01"),
  end: new Date("2026-08-01"),
  dimensions: ["COUNTRY", "PRICING_CATEGORY"],
  metricTypes: ["COST", "VOLUME"],
});
const days = aggregateByDay(result.points);
KindGraph fieldMetricsGranularities
messaginganalyticssent, deliveredHALF_HOUR, DAY, MONTH
conversationconversation_analyticsconversation, costHALF_HOUR, DAILY, MONTHLY
pricingpricing_analyticscost, volumeHALF_HOUR, DAILY, MONTHLY

analytics says DAY/MONTH where the other two say DAILY/MONTHLY. That asymmetry is Meta's; buildAnalyticsField validates against the right list per kind.

Trap 1 — retention is one year

Meta's lookback dropped from ten years to one on 2025-12-01. Past that horizon the data does not exist on their side and never will again. clampToRetention pulls a start date up to the floor and sets truncated, so a three-year request comes back short and says so instead of quietly returning a hole.

Our archive is the long-term record, not a cache. lib/analytics/messaging.ts upserts one AnalyticsSnapshot row per UTC day per kind on every fetch. Upsert, not insert: re-reading an overlapping window — exactly what a nightly job does with today's partial day — must replace the row, never double it. GET /api/v1/analytics?source=archive reads it back, and is the only way to see anything older than a year.

Trap 2 — COST is absent, not zero

A WABA billed through a partner credit line gets no cost back from Meta at all: the key is simply missing. Defaulting it to 0 would produce a revenue report reading "we spent nothing", which is the most expensive mistake this module could make. So cost is a union, never a number:

type CostValue = { available: true; amount: number } | { available: false; reason: string };

kind=messaging never carries cost by construction (COST_UNAVAILABLE.notInMetric) — ask for pricing or conversation.

aggregateByDay sums a day's cost only when every point of that day reported one. A day Meta priced halfway yields COST_UNAVAILABLE.partial and no total: an understated bill is worse than an absent one, and the per-point breakdown is still there for whoever wants to reason about it.

On this page