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.
| Concern | Module | Genuka WA route |
|---|---|---|
| Business profile | management/profile.ts | GET/PATCH /api/v1/profile |
| Welcome message, ice breakers, commands | management/automation.ts | GET/PATCH /api/v1/conversation-settings |
| Blocked users | management/blocklist.ts | GET/POST/DELETE /api/v1/blocked-users |
| QR codes and short links | management/qr.ts | /api/v1/qr-codes, /api/v1/qr-codes/{code} |
| Number health | management/health.ts | GET /api/v1/numbers, GET /api/v1/numbers/{id}/health |
| Messaging / cost analytics | management/analytics.ts | GET /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",
});| Field | Limit |
|---|---|
about | 139 characters |
address | 256 |
description | 512 |
email | 128 |
websites | 2 max, 256 characters each, absolute URL with a scheme |
vertical | closed 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
nullclears the field.buildBusinessProfileUpdateonly emits the keys you actually named./api/v1/profilePATCH 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"] }),
);| Component | Limit |
|---|---|
| Ice breakers | 4 max, 80 characters each |
| Commands | 30 max; name ≤ 32 characters, description ≤ 256 |
| Welcome message | boolean toggle |
Three things to know:
- The POST replaces the whole object. Sending only
promptsdeletes the commands, and Meta answers 200. AlwaysmergeConversationalAutomationonto a fresh read — that is what/api/v1/conversation-settingsPATCH does. welcomeMessageEnabledsends nothing on its own. It makes WhatsApp emit arequest_welcomewebhook 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.- 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 }] }| Constraint | Value |
|---|---|
| Numbers per request | 1 000 (limits.BLOCKLIST.perRequestMax) |
| Total blocklist size | 64 000 (limits.BLOCKLIST.totalMax) |
| Eligibility | the 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:
| Code | Hint |
|---|---|
131047 | no inbound message in the last 24 h — the conversation must still be open |
139101 | this 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.
QR codes and short links
/{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 }| Constraint | Value |
|---|---|
prefilled_message | 140 characters (limits.QR_CODE.prefilledMessageMax) |
| Codes per number | 2 000 (limits.QR_CODE.perNumberMax) |
| Image formats | SVG, 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.melink. 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.
| Alert | Severity |
|---|---|
quality_dropped to YELLOW | warning |
quality_dropped to RED | critical |
quality_recovered | info |
messaging_limit_changed, downgrade | warning |
messaging_limit_changed, upgrade | info |
throughput_changed, HIGH → lower | warning |
status_changed to FLAGGED / RATE_LIMITED | warning |
status_changed to RESTRICTED / BANNED | critical |
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.
UNKNOWNquality 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 flipsGREEN→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
statusagainst the Genukastatuscolumn. Ours is a lifecycle value (connected,offboarded, …), Meta's isCONNECTED/FLAGGED/ …; diffing the two vocabularies raises an alert on every read./api/v1/numbers/{id}/healthkeeps them apart asmetaStatusandgenukaStatus.
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);| Kind | Graph field | Metrics | Granularities |
|---|---|---|---|
messaging | analytics | sent, delivered | HALF_HOUR, DAY, MONTH |
conversation | conversation_analytics | conversation, cost | HALF_HOUR, DAILY, MONTHLY |
pricing | pricing_analytics | cost, volume | HALF_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.