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.

  • Integrations
  • $whatsapp-business-api
  • 171 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- whatsapp-business-api

Installs to ~/.agents/skills/whatsapp-business-api/ for every project on this machine.

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

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.

~/.agents/skills/whatsapp-business-api/SKILL.md

--- name: whatsapp-business-api description: Integrates the WhatsApp Business Platform Cloud API - sending template and session messages through the Graph API, the 24-hour customer service… license: MIT ---

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

  1. 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.
  2. 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).
  3. Pin a Graph API version in one constant (for example v23.0) and upgrade deliberately; versions expire roughly two years after release.
  4. 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

RuleConsequence
A customer service window opens for 24 h after each inbound user messageInside it: free-form text, media, interactive messages. Outside: templates only
Templates are pre-approved by Meta and have a categorymarketing, 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 youRecord when, where and how consent was given; honour opt-out keywords
Messaging limits cap unique users per rolling 24 hNew 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}/media and reuse the media ID, or send a public HTTPS link. 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 the message_template_status_update webhook 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

CodeMeaningAction
131047More than 24 h since the user’s last messageSend a template instead
131026Message undeliverable (not on WhatsApp, old app, or blocked)Mark number, fall back to SMS or email if consented
132000 / 132001Template parameter mismatch or template missing for languageFix parameters or language code
130429 / 131056Throughput or pair rate limitBack off; spread sends to one user
190Access token expired or invalidRotate the System User token

Gotchas

  • Test tokens from the app dashboard expire in 24 hours; production needs a System User token with whatsapp_business_messaging and whatsapp_business_management permissions.
  • 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_update webhook 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, read statuses recorded against the stored wamid.
  • 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem