Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion plugins/kapso/skills/observe-whatsapp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ Fallback path:
2. API logs: `node scripts/api-logs.js`
3. Webhook deliveries: `node scripts/webhook-deliveries.js`

To map a WhatsApp error code (e.g. `131049` "healthy ecosystem engagement" marketing cap) to remediation, use the error-code table in [references/message-debugging-reference.md](references/message-debugging-reference.md).

### Triage broadcast (campaign) failures

Broadcast delivery data lives in the platform API, not PostHog analytics. Use this when a campaign shows a high failure rate, or when the broadcast detail page won't load (the API reads the same data).

1. List failing broadcasts: `node scripts/broadcasts.js --status failed`
2. Inspect one broadcast's counts: `node scripts/broadcasts.js --broadcast-id <id>`
3. Drill into failed recipients (with error codes): `node scripts/broadcasts.js --broadcast-id <id> --recipients --failed-only`
4. Map recipient `error_details.code` / `error_message` to remediation via [references/message-debugging-reference.md](references/message-debugging-reference.md). See [references/triage-reference.md](references/triage-reference.md) for the full broadcast triage flow.

### Run health checks

Preferred path:
Expand Down Expand Up @@ -74,6 +85,7 @@ Fallback path:
| `errors.js` | List message errors |
| `api-logs.js` | List external API logs |
| `webhook-deliveries.js` | List webhook delivery attempts |
| `broadcasts.js` | List broadcasts, inspect one, and drill into failed recipients |

### Health

Expand Down Expand Up @@ -123,7 +135,7 @@ node scripts/openapi-explore.mjs --spec platform schema WebhookDelivery
|.:{package.json,SKILL.md}
|assets:{health-example.json,message-debugging-example.json,triage-example.json}
|references:{health-reference.md,message-debugging-reference.md,triage-reference.md}
|scripts:{api-logs.js,errors.js,lookup-conversation.js,message-details.js,messages.js,openapi-explore.mjs,overview.js,webhook-deliveries.js,whatsapp-health.js}
|scripts:{api-logs.js,broadcasts.js,errors.js,lookup-conversation.js,message-details.js,messages.js,openapi-explore.mjs,overview.js,webhook-deliveries.js,whatsapp-health.js}
|scripts/lib/messages:{args.js,kapso-api.js}
|scripts/lib/status:{args.js,kapso-api.js}
|scripts/lib/triage:{args.js,kapso-api.js}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,65 @@

1. Identify the message ID (`wamid.*`).
2. Review the status timeline in order: sent -> delivered -> read.
3. Surface error codes in status events and map to remediation.
3. Surface error codes and map them to remediation (see the error-code table below).

### Where error codes live

- Per-message: `kapso.status` is the headline (`pending`/`sent`/`delivered`/`read`/`failed`).
The raw Meta status events — including any `errors[].code` and `errors[].title` — are in
`kapso.statuses[]` (raw Meta payloads).
- Per-broadcast recipient: `error_message` (string) and `error_details` (the Meta error
payload, with the numeric `code`). See "Broadcast / campaign failures" in
[triage-reference.md](triage-reference.md).

## WhatsApp error-code reference

Codes are Meta WhatsApp Cloud API error codes. Lead with whether the failure is **expected**
(throttling/policy that resolves on its own) or **actionable** (a fix the user can make).

### Messaging limits and policy (the "high failure rate" cases)

| Code | Meaning | Expected? | Remediation |
|------|---------|-----------|-------------|
| `131049` | "This message was not delivered to maintain healthy ecosystem engagement." Meta's per-user **marketing-template frequency cap** — the recipient has already received enough marketing messages in the window. | Yes — not a bug | Don't retry the same marketing template at the same recipient. Reduce marketing volume per user, space sends out, and prioritize **utility/authentication** templates (not subject to the cap) for transactional content. Improve opt-in quality and relevance. A high `131049` share on a broadcast usually means too much marketing to the same audience, not a delivery defect. |
| `130472` | Recipient is part of Meta's marketing-message **experiment group**; the marketing message was intentionally not delivered. | Yes — not a bug | Same as `131049`: treat as expected suppression, not a fixable error. |
| `131048` | Spam rate limit hit — sending restricted because of low quality / too many sends. | Partly | Slow down sends and improve message quality. Check the number's quality rating and messaging health (run a health check). |
| `131031` | Business account locked or restricted (policy violation). | No | Escalate — the WABA is restricted. Check WhatsApp Manager account status; contact support. |
| `368` | Temporarily blocked for policy violations. | No | Review recent policy violations in WhatsApp Manager; resolve before resuming sends. |

### Delivery failures (recipient / window / throughput)

| Code | Meaning | Remediation |
|------|---------|-------------|
| `131026` | Message undeliverable — recipient can't receive it (not a WhatsApp user, hasn't accepted updated ToS, or the business isn't allowed to message them). | Confirm the number is on WhatsApp and correctly formatted (E.164, no `+` in API payloads). Don't keep retrying a number that consistently returns this. |
| `131047` | Re-engagement required — more than 24h since the user's last message, so free-form text is blocked. | Use an approved **template** to reopen the conversation; free-form messages only work inside the 24-hour customer-service window. |
| `131051` | Unsupported message type. | Send a supported type for the recipient/region. |
| `131053` | Media upload/download error. | Re-upload the media; verify size/format limits and that the media URL is reachable. |
| `130429` / `80007` | Rate limit (throughput) reached. | Back off and retry with spacing; reduce concurrent send rate. Transient. |
| `131000` | Generic "something went wrong" on Meta's side. | Transient — retry. If it persists, capture the message ID and `kapso.statuses[]` for escalation. |

### Template errors (business-initiated sends)

| Code | Meaning | Remediation |
|------|---------|-------------|
| `132000` | Number of template parameters doesn't match the template. | Send exactly the parameters the approved template defines. |
| `132001` | Template doesn't exist or isn't approved in this language. | Verify the template name + language code; confirm it's APPROVED for the WABA. |
| `132005` | Hydrated template text is too long. | Shorten parameter values so the rendered message fits Meta's limit. |
| `132007` / `132015` | Template paused for low quality. | The template is paused by Meta; fix content quality or use a different approved template. |
| `132012` | Parameter format mismatch. | Match the expected parameter format (e.g. currency/date/number components). |
| `132016` | Template disabled. | Template was disabled; resubmit or switch templates. |

### Eligibility / auth

| Code | Meaning | Remediation |
|------|---------|-------------|
| `131042` | Business eligibility / payment-method issue. | Templates blocked until billing is fixed; 24-hour-window messages still work. Fix the payment method in WhatsApp Manager. |
| `133xxx` | Phone-number registration errors. | The number isn't correctly registered with Cloud API. Re-run health checks; escalate if registration is incomplete. |
| `141000` | See [health-reference.md](health-reference.md) §12 — contact support. |

> This is not exhaustive. For an unlisted code, report the numeric code + Meta `title`/message
> verbatim, say whether it looks transient (retry) or actionable, and point the user at Meta's
> Cloud API error reference rather than guessing.

## Common issues to confirm

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,39 @@
2. Review token validity, messaging health, and webhook subscription.
3. Explain whether the issue is critical or degraded.

## Broadcast / campaign failures

Broadcast (campaign) data lives in the **platform API**, not in PostHog analytics — there is no
broadcast or delivery-status event to query in product analytics, so triage from the API.

Use this when a campaign shows a **high failure rate** or when the broadcast detail page in the
app won't load (the API is the workaround to read the same data).

1. **Find the broadcast and read its counts.** List broadcasts (optionally `--status failed`)
and inspect one. The `WhatsappBroadcast` carries `sent_count`, `failed_count`,
`delivered_count`, `read_count`, `pending_count`, `response_rate`, and `status`
(`draft`/`scheduled`/`sending`/`stopped`/`completed`/`failed`).
- `node scripts/broadcasts.js --status failed`
- `node scripts/broadcasts.js --broadcast-id <id>`
2. **Drill into failed recipients to get the error codes.** Each `WhatsappBroadcastRecipient`
carries `status`, `error_message`, and `error_details` (the Meta error payload with the
numeric `code`).
- `node scripts/broadcasts.js --broadcast-id <id> --recipients --failed-only`
3. **Map the codes to remediation** using the error-code table in
[message-debugging-reference.md](message-debugging-reference.md).
- A large share of `131049` (or `130472`) is **expected** — Meta's per-user marketing-message
cap, not a delivery defect. Lead with that: the campaign sent too many marketing templates
to the same audience; reduce frequency and prefer utility/authentication templates. Do not
report it as a bug to fix.
- Codes like `131026`, `131047`, `132xxx` point at recipient/window/template problems that
*are* actionable.
4. **Cross-reference** `api-logs` and `webhook-deliveries` only if the failures look like
transport/API problems (auth, rate limit, upstream 5xx) rather than per-recipient Meta codes.

The OpenAPI broadcast endpoints (`listWhatsappBroadcasts`, `getWhatsappBroadcast`,
`listWhatsappBroadcastRecipients`) can be explored with
`node scripts/openapi-explore.mjs --spec platform search "broadcast"`.

## Webhook delivery failures

1. Review recent delivery attempts.
Expand Down
112 changes: 112 additions & 0 deletions plugins/kapso/skills/observe-whatsapp/scripts/broadcasts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
const { hasHelpFlag, parseFlags } = require('./lib/triage/args');
const { kapsoConfigFromEnv, kapsoRequest } = require('./lib/triage/kapso-api');

function err(message, details) {
return { ok: false, error: { message, details } };
}

async function main() {
const argv = process.argv.slice(2);
if (hasHelpFlag(argv)) {
console.log(
JSON.stringify(
{
ok: true,
usage: [
'List broadcasts: node scripts/broadcasts.js [--status <draft|sending|completed|failed>] [--phone-number-id <id>] [--created-after <iso>] [--created-before <iso>] [--per-page <n>] [--page <n>]',
'Get one broadcast: node scripts/broadcasts.js --broadcast-id <id>',
'List recipients: node scripts/broadcasts.js --broadcast-id <id> --recipients [--failed-only] [--per-page <n>] [--page <n>]'
],
notes: [
'Broadcast data lives in the platform API, not PostHog analytics.',
'Failed recipients carry error_message and error_details (Meta error payload with numeric code).',
'Map error codes (e.g. 131049 marketing cap) using references/message-debugging-reference.md.',
'--failed-only filters the returned recipients client-side (the API has no status filter).'
],
env: ['KAPSO_API_BASE_URL', 'KAPSO_API_KEY']
},
null,
2
)
);
return 0;
}

try {
const flags = parseFlags(argv);
const broadcastId = flags['broadcast-id'];
const wantsRecipients = flags.recipients === true || flags.recipients === 'true';

if (wantsRecipients && (!broadcastId || broadcastId === true)) {
throw new Error('--recipients requires --broadcast-id <id>');
}

const config = kapsoConfigFromEnv();

if (wantsRecipients) {
const params = new URLSearchParams();
if (flags['per-page']) params.set('per_page', flags['per-page']);
if (flags.page) params.set('page', flags.page);

const suffix = params.toString();
const path = `/platform/v1/whatsapp/broadcasts/${encodeURIComponent(broadcastId)}/recipients${
suffix ? `?${suffix}` : ''
}`;
const response = await kapsoRequest(config, path);

const failedOnly = flags['failed-only'] === true || flags['failed-only'] === 'true';
if (failedOnly && response && Array.isArray(response.data)) {
const filtered = response.data.filter((recipient) => isFailedRecipient(recipient));
console.log(
JSON.stringify(
{ ok: true, broadcast_id: broadcastId, failed_only: true, data: filtered, meta: response.meta },
null,
2
)
);
return 0;
}

console.log(JSON.stringify({ ok: true, broadcast_id: broadcastId, data: response }, null, 2));
return 0;
}

if (broadcastId && broadcastId !== true) {
const path = `/platform/v1/whatsapp/broadcasts/${encodeURIComponent(broadcastId)}`;
const data = await kapsoRequest(config, path);
console.log(JSON.stringify({ ok: true, data }, null, 2));
return 0;
}

const params = new URLSearchParams();
if (flags.status) params.set('status', flags.status);
if (flags['phone-number-id']) params.set('phone_number_id', flags['phone-number-id']);
if (flags['created-after']) params.set('created_after', flags['created-after']);
if (flags['created-before']) params.set('created_before', flags['created-before']);
if (flags['per-page']) params.set('per_page', flags['per-page']);
if (flags.page) params.set('page', flags.page);

const suffix = params.toString();
const data = await kapsoRequest(
config,
`/platform/v1/whatsapp/broadcasts${suffix ? `?${suffix}` : ''}`
);

console.log(JSON.stringify({ ok: true, data }, null, 2));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(err('Command failed', { message }), null, 2));
return 1;
}
}

function isFailedRecipient(recipient) {
if (!recipient || typeof recipient !== 'object') return false;
if (recipient.failed_at) return true;
if (recipient.error_message || recipient.error_details) return true;
const status = String(recipient.status || '').toLowerCase();
return status === 'failed';
}

main().then((code) => process.exit(code));
1 change: 1 addition & 0 deletions plugins/kapso/skills/observe-whatsapp/scripts/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async function main() {
const path = `/platform/v1/whatsapp/messages?${params.toString()}`;
result.sources.message_delivery = await kapsoRequest(config, path);
result.notes.push('Message failures use outbound status=failed and do not support period filtering.');
result.notes.push('For broadcast/campaign failures (e.g. high 131049 marketing-cap rates), use scripts/broadcasts.js — broadcast delivery data is not included here.');
}

if (!source || source === 'api_call') {
Expand Down
Loading