WhatsApp Business API skill for Codex
Templates outside 24 hours, free-form inside. Codex sends WhatsApp messages through Meta's Cloud API while respecting the rules that decide delivery and cost: opt-in, the 24-hour customer service window, and template categories. It verifies X-Hub-Signature-256 with the App Secret, records every wamid through sent, delivered and read, maps error 131047 to a template fallback, and keeps template definitions in the repo.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- whatsapp-business-api Installs to ~/.agents/skills/whatsapp-business-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install whatsapp-business-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project whatsapp-business-api Installs to .agents/skills/whatsapp-business-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/whatsapp-business-api.zipunzip -o whatsapp-business-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) whatsapp-business-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $whatsapp-business-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates the WhatsApp Business Platform Cloud API - sending template and session messages through the Graph API, the 24-hour customer service window, template categories and approval, interactive buttons and lists, media, webhook verification with X-Hub-Signature-256, delivery statuses, opt-in and per-message pricing. Use when sending WhatsApp notifications or OTPs from an app, building a WhatsApp support bot, handling message status webhooks, or debugging error 131047 re-engagement or template rejection.
Use it when
- Sending WhatsApp notifications, OTPs or support replies from an app
- Designing message templates and choosing their category
- Handling inbound messages and delivery status webhooks
- Moving from a 24-hour test token to production credentials
Use something else when
- You need plain SMS or voice calls: use twilio-sms
- The bot is for Telegram users: use telegram-bots
How to check the result
The webhook handshake succeeds, a wrongly signed POST is rejected, and a template sent to an allow-listed test number records sent, delivered and read against its wamid.
The mistake it prevents
Trying to send free-form text more than 24 hours after the user's last message, which fails with 131047; only an approved template can reopen the conversation.
Done well means: users opted in before you message them, anything outside the 24-hour customer service window uses an approved template, webhooks are verified and processed idempotently, delivery status is tracked per message, and no message reaches a real number without the user’s confirmation of recipients and content.
Before writing code
- Read
AGENTS.md; find any existing WhatsApp client or a BSP (Twilio, 360dialog, MessageBird) wrapper. A BSP has its own API shape; this skill targets Meta’s Cloud API directly. - Collect configuration (env vars, never committed):
WA_PHONE_NUMBER_ID,WA_WABA_ID,WA_ACCESS_TOKEN(a System User token, not a 24-hour test token),WA_APP_SECRET(for webhook signatures),WA_VERIFY_TOKEN(your own random string for the GET handshake). - Pin a Graph API version in one constant (for example
v23.0) and upgrade deliberately; versions expire roughly two years after release. - Sending to real numbers, creating or editing templates, and bulk sends need explicit user confirmation. Use the test number and allow-listed recipients during development.
All calls go to graph.facebook.com and need network access; in a Codex sandbox, ask for
approval or mock the HTTP client.
The rules that shape every design
| Rule | Consequence |
|---|---|
| A customer service window opens for 24 h after each inbound user message | Inside it: free-form text, media, interactive messages. Outside: templates only |
| Templates are pre-approved by Meta and have a category | marketing, utility, authentication; category drives price and review |
| Pricing is per delivered template message (since July 2025) | Non-template messages and utility templates inside an open window are free; check Meta’s rate card per country |
| Users must opt in to receive messages from you | Record when, where and how consent was given; honour opt-out keywords |
| Messaging limits cap unique users per rolling 24 h | New numbers start low; quality rating affects growth |
Send messages
const GRAPH = `https://graph.facebook.com/${process.env.WA_API_VERSION ?? "v23.0"}`;
async function waSend(body: object) {
const res = await fetch(`${GRAPH}/${process.env.WA_PHONE_NUMBER_ID}/messages`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.WA_ACCESS_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ messaging_product: "whatsapp", ...body }),
});
const json = await res.json();
if (!res.ok) throw new WhatsAppError(json.error?.code, json.error?.message, json.error?.error_data?.details);
return json.messages[0].id as string; // wamid..., store it to match status webhooks
}
// Template (works any time, if approved)
await waSend({
to: "15551234567",
type: "template",
template: {
name: "order_shipped",
language: { code: "en_US" },
components: [
{ type: "body", parameters: [{ type: "text", text: order.customerName }, { type: "text", text: order.id }] },
{ type: "button", sub_type: "url", index: "0", parameters: [{ type: "text", text: order.trackingToken }] },
],
},
});
// Session message with reply buttons (only inside the 24 h window)
await waSend({
to, type: "interactive",
interactive: {
type: "button",
body: { text: "Did this answer your question?" },
action: { buttons: [
{ type: "reply", reply: { id: "helpful_yes", title: "Yes" } },
{ type: "reply", reply: { id: "helpful_no", title: "No, talk to a person" } },
] },
},
});
- Phone numbers in international format, digits only is safest (
15551234567). - Reply buttons: up to 3, titles up to 20 characters. List messages: up to 10 rows total.
- Template parameters must match the approved template exactly (count, order, format). Named parameters are supported on newer templates; check the template definition before coding.
- Media: upload once via
/{phone-number-id}/mediaand reuse the media ID, or send a public HTTPSlink. Media IDs expire, so do not cache them indefinitely.
Templates
- Create in WhatsApp Manager or via
POST /{waba-id}/message_templates. Approval usually takes minutes to hours; rejections cite a reason. Keep templates in the repo (name, category, language, body with{{1}}placeholders, sample values) so changes are reviewed. - Utility templates must be about a specific transaction or account event the user expects. Promotional wording gets them recategorised as marketing.
- Authentication templates have a fixed format with a copy-code or one-tap button; do not try to send OTPs through utility templates.
- Template status changes (
APPROVED,REJECTED,PAUSED,DISABLED) arrive on themessage_template_status_updatewebhook field. Paused templates fail to send: alert on it.
Webhooks
// GET handshake when you save the callback URL
app.get("/webhooks/whatsapp", (req, res) => {
const ok = req.query["hub.mode"] === "subscribe" && req.query["hub.verify_token"] === process.env.WA_VERIFY_TOKEN;
ok ? res.status(200).send(req.query["hub.challenge"]) : res.sendStatus(403);
});
// POST events: verify X-Hub-Signature-256 over the raw body with the App Secret
app.post("/webhooks/whatsapp", express.raw({ type: "application/json" }), async (req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", process.env.WA_APP_SECRET!).update(req.body).digest("hex");
const got = req.header("x-hub-signature-256") ?? "";
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401);
}
res.sendStatus(200); // ack fast, process async
const payload = JSON.parse(req.body.toString());
for (const entry of payload.entry ?? []) for (const change of entry.changes ?? []) {
const v = change.value;
for (const m of v.messages ?? []) await enqueueInbound(m); // dedupe on m.id
for (const s of v.statuses ?? []) await recordStatus(s.id, s.status, s.errors); // sent, delivered, read, failed
}
});
Subscribe the app to the messages field on the WABA. Meta retries failed deliveries, so
dedupe inbound messages on id and statuses on id plus status. Statuses can arrive out of
order (read before delivered); keep the furthest state.
Error codes worth handling
| Code | Meaning | Action |
|---|---|---|
| 131047 | More than 24 h since the user’s last message | Send a template instead |
| 131026 | Message undeliverable (not on WhatsApp, old app, or blocked) | Mark number, fall back to SMS or email if consented |
| 132000 / 132001 | Template parameter mismatch or template missing for language | Fix parameters or language code |
| 130429 / 131056 | Throughput or pair rate limit | Back off; spread sends to one user |
| 190 | Access token expired or invalid | Rotate the System User token |
Gotchas
- Test tokens from the app dashboard expire in 24 hours; production needs a System User token
with
whatsapp_business_messagingandwhatsapp_business_managementpermissions. - The test phone number only sends to allow-listed recipients.
- Webhook signatures use the App Secret, not the access token or verify token.
- Do not send marketing templates to users who opted in only for transactional updates.
- Quality rating drops when users block or report you; it can pause templates and restrict
limits. Watch the
phone_number_quality_updatewebhook field.
Verification
- Saving the callback URL in the app dashboard succeeds (GET handshake works).
- A request with a wrong signature gets 401; a valid one is processed once even if replayed.
- Send a template to an allow-listed test number and see
sent,delivered,readstatuses recorded against the storedwamid. - Reply from the test phone, then send a session message; wait past 24 h (or simulate) and confirm the code switches to a template on 131047.
Report template
**API:** WhatsApp Cloud API, Graph v23.0, phone number ID in WA_PHONE_NUMBER_ID
**Templates:** order_shipped (utility, en_US), login_code (authentication); definitions in templates/whatsapp/
**Webhooks:** GET verify + POST with X-Hub-Signature-256; fields: messages, message_template_status_update
**Tracking:** wa_messages(wamid, to, template, status, error_code)
**Opt-in:** checkbox at checkout, stored with timestamp and source
**Needs user:** template approval, System User token, production number, sends to real recipients What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "WhatsApp Business API"
short_description: "Templates outside 24 hours, free-form inside"
default_prompt: "$whatsapp-business-api Send a WhatsApp order-shipped notification with a tracking button from our backend" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.