Use when wiring an app or agent to message customers over the WhatsApp Cloud API or a Telegram bot โ order updates, OTP codes, reminders, alerts and broadcasts โ or when debugging undelivered messages, 24-hour-window errors, rejected templates, webhook signature verification, or rate limits and their retry headers. NOT the reply content you send (that is `customer-support`).
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Use when wiring an app or agent to message customers over the WhatsApp Cloud API or a Telegram bot โ order updates, OTP codes, reminders, alerts and broadcasts โ or when debugging undelivered messages, 24-hour-window errors, rejected templates, webhook signature verification, or rate limits and their retry headers. NOT the reply content you send (that is `customer-support`).
You are wiring code that pushes messages to real customers. Both platforms have sharp, silent rules: code that passes in testing 403s or drops in production. This skill is the transport pipe โ endpoints, auth, windows, rate limits, webhooks. The words you send are out of scope (that is ../customer-support/SKILL.md).
Pick your platform
If the customer is...
Use
Why
already on WhatsApp, identity-verified, you have their phone number
WhatsApp Cloud API
Highest reach + trust; but and .
template-gated
billed per message
an opt-in bot subscriber (clicked "Start")
Telegram Bot API
Free, instant, dev-friendly, no template gate, but reach = people who joined your bot.
You can ship both โ but the rules do not transfer. WhatsApp's 24-hour window and template gate have no Telegram equivalent; Telegram's 30 msg/s ceiling has no WhatsApp equivalent.
WhatsApp Cloud API
Env + endpoint
Never hardcode credentials. Four values come from the Meta App + WhatsApp Business Account (WABA):
WA_TOKEN=... # Bearer access token (System User token in prod, not a temp one)
WA_PHONE_NUMBER_ID=... # the sending number's ID, not the phone number itself
WA_WABA_ID=... # WhatsApp Business Account ID (for template management)
WA_APP_SECRET=... # app secret, used to verify inbound webhook signatures
Send endpoint, with the version pinned in the path:
POST https://graph.facebook.com/v25.0/{WA_PHONE_NUMBER_ID}/messages
Authorization: Bearer ${WA_TOKEN}
Content-Type: application/json
Pin the version (v25.0 is current, announced 2026-02-18; v24.0 is the lowest still supported). Why: a versionless URL drifts onto whatever Meta defaults to and breaks payload shape without warning. Never call graph.facebook.com/{id}/messages bare.
The 24-hour customer-service window (the load-bearing rule)
A free-form message can ONLY be sent inside a 24-hour window that the user opened by messaging you. Outside that window you MUST send a pre-approved template to re-engage โ a free-form send out-of-window fails with error #131047 (re-engagement message required).
Window state
What you may send
Cost
Open (user messaged < 24h ago)
Any free-form text / media / interactive
Free
Open
Utility template
Free (in-window)
Closed (no recent user message)
Approved template only
Billed per message (marketing/auth rates)
Closed + you send free-form
nothing โ #131047
n/a
So branch on window state before every send: in-window โ free-form is fine; otherwise โ reach for a template.
Send free-form text (in-window)
curl -sS -X POST "https://graph.facebook.com/v25.0/${WA_PHONE_NUMBER_ID}/messages" \
-H "Authorization: Bearer ${WA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"messaging_product":"whatsapp","to":"34699999999","type":"text","text":{"body":"Your order #1234 shipped."}}'
const res = awaitfetch(
`https://graph.facebook.com/v25.0/${process.env.WA_PHONE_NUMBER_ID}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WA_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: "34699999999",
type: "text",
text: { body: "Your order #1234 shipped." },
}),
},
);
const data = await res.json();
const messageId = data.messages?.[0]?.id; // capture it โ your only handle for delivery tracing
Always log messages[0].id from the response. It is the only key that ties a send to the later delivery/read webhook.
Send a template (out-of-window or any business-initiated message)
const body = {
messaging_product: "whatsapp",
to: "34699999999",
type: "template",
template: {
name: "appointment_reminder", // must be APPROVED in WhatsApp Manager firstlanguage: { code: "ca" },
components: [
{
type: "body",
parameters: [
{ type: "text", text: "Maria" },
{ type: "text", text: "dimarts a les 10:00" },
],
},
],
},
};
Templates are created and approved via WhatsApp Business Manager or the POST /{WA_WABA_ID}/message_templates API before they can be sent. Variable order in parameters must match the {{1}} {{2}} placeholders in the approved template body.
Template categories + cost
Four categories: marketing, utility, authentication, service. Category drives both policy and price.
Billing changed on 2025-07-01: conversation-based pricing is deprecated, replaced by per-message pricing โ you are billed per delivered template message, rate by category + recipient country. Free-form messages inside an open window are free; utility templates inside an open window are free. Do not reason about old "per-conversation" pricing โ it is gone. Full category/price table โ references/whatsapp-cloud-api.md.
Policy note: since 2026-01-15 Meta prohibits general-purpose AI assistants (open ChatGPT-wrapper bots) on WhatsApp. Business automation is fine; a generic chatbot is not. For conversational design see ../customer-support/SKILL.md.
Inbound webhook
GET verify โ Meta calls your callback URL once with hub.mode, hub.verify_token, hub.challenge. If hub.verify_token matches your configured token, echo back hub.challenge (plain, 200).
POST events โ every event POST carries X-Hub-Signature-256: sha256=<hmac>. Compute HMAC-SHA256(rawBody, WA_APP_SECRET) and compare. Reject unsigned/mismatched bodies โ without this anyone can forge inbound events.
parse_mode is HTML | MarkdownV2 | Markdown (legacy). MarkdownV2 requires escaping the reserved set _*[]()~\>#+-=|{}.!with a backslash, or the call 400s. HTML is safer for dynamic text. Text cap is **4096 chars** per message โ split longer payloads into chunks. Escape table + chunking โreferences/telegram-bot-api.md`.
Rate limits (the silent killer)
Broadcast ceiling: ~30 messages/second across all chats.
Same chat: ~1 message/second.
Exceed either โ HTTP 429 with a JSON parameters.retry_after (seconds). Honor it: sleep retry_after seconds, then retry. Do not blast-retry โ you will be throttled harder.
Need more than 30/s? Enable Paid Broadcasts via @BotFather (up to 1000 msg/s, 0.1 Telegram Stars per excess message).
asyncfunctiontgSend(payload: object, tries = 5): Promise<Response> {
for (let i = 0; i < tries; i++) {
const res = awaitfetch(`https://api.telegram.org/bot${process.env.TG_TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status !== 429) return res;
const { parameters } = await res.json();
awaitnewPromise((r) =>setTimeout(r, (parameters?.retry_after ?? 1) * 1000));
}
thrownewError("Telegram: rate-limited after retries");
}
Inbound: setWebhook XOR getUpdates
Pick exactly one โ never run both, they conflict.
setWebhook(url, ...) โ Telegram POSTs updates to your URL. Set a secret_token; Telegram echoes it back in the X-Telegram-Bot-Api-Secret-Token header โ verify it. Optional allowed_updates (filter types) and max_connections (1โ100, default 40).
getUpdates โ long-poll loop, good for local dev / single-instance bots. Calling getUpdates while a webhook is set returns an error; call deleteWebhook first.