WhatsApp Business Cloud API Tutorial: Send Your First Message
A practical, current beginner tutorial for Meta's WhatsApp Business Cloud API: spin up a Meta app, get your phone number ID and access token, send your first hello_world template and a free-form text with curl and Node, then wire up webhooks the right way.
- The WhatsApp Business Cloud API is Meta's free, hosted, fully supported path. The legacy On-Premises API is gone - its last supported client version expired on October 23, 2025, so ignore any tutorial that references it.
- Create a Business-type Meta app, add the WhatsApp product, and you get a free test number plus two IDs you must keep: the Phone Number ID and the WhatsApp Business Account (WABA) ID.
- Send messages with POST https://graph.facebook.com/v25.0/<PHONE_NUMBER_ID>/messages and a Bearer token. Start with the hello_world template, then a free-form text inside the 24h window.
- The temporary dashboard token lasts under 24 hours and is testing-only. Production needs a permanent System User token, business verification, and prior user opt-in.
- Webhooks need a verify-token handshake (echo hub.challenge) and X-Hub-Signature-256 HMAC validation against the raw body using a constant-time compare.
What you're building (and why Cloud API)
By the end of this tutorial you'll have sent a real WhatsApp message from your terminal and from Node.js, and you'll understand the rules that govern when and what you can send. We'll use Meta's WhatsApp Business Cloud API - the official, Meta-hosted gateway that sits on top of the Graph API. It's free to use (you only pay per delivered template message), and crucially it's the only sanctioned and currently supported messaging product on the WhatsApp Business Platform.
A quick map of the terrain so the names don't trip you up. The WhatsApp Business Platform is the umbrella. Underneath it, the Cloud API is the messaging product you want. There used to be a self-hosted On-Premises API, but Meta deprecated it and the last supported On-Premises client version expired on October 23, 2025 - so if you find an old guide telling you to run a Docker container with your own WhatsApp coreapp, close that tab. A separate Business Management API exists for managing accounts, templates and phone numbers, but for sending messages, Cloud API is the whole story.
Prerequisites
You need three things before you write any code, and none of them cost money to get started:
- A Facebook account you can log into at developers.facebook.com (Meta for Developers).
- A Meta Business Account (also called a business portfolio) - the dashboard will offer to create one during setup if you don't have it.
- A phone you can receive WhatsApp messages on, to act as your test recipient. You do not need a spare SIM: Meta gives you a free test sender number automatically.
You do not need business verification, a real phone number, or a paid plan to complete this tutorial. Those only become relevant when you move to production, which we cover at the end.
Step 1: Create a Meta app and add WhatsApp
Head to the Meta App Dashboard at developers.facebook.com/apps and click Create app. When asked for an app type, choose Business - this is the type that unlocks the WhatsApp product. Give the app a name, link it to your business portfolio (or create one), and finish.
On the new app's dashboard, find the WhatsApp product and click Set up. Meta provisions a free test business phone number for you on the spot. This is the magic that lets you test without owning a real number, with one catch we'll hit in the next step.
From the WhatsApp > API Setup (sometimes labelled Getting Started) screen, copy down two identifiers. You'll paste them into every API call, so keep them somewhere safe:
- Phone Number ID - the numeric ID of your test sender number (NOT the phone number itself). This goes in the request URL.
- WhatsApp Business Account (WABA) ID - identifies the account that owns your number and templates. You'll need it for template and webhook management.
Step 2: Grab your temporary access token
On the same API Setup screen there's a Temporary access token. Copy it. This token authorizes your requests, and it's the fastest way to get sending - but it expires in under 24 hours (commonly around 23 hours) and is strictly for testing. We'll set up a proper permanent token for production later.
Treat the token like a password. Anyone holding it can send messages as your business and rack up charges, so never commit it to git, never put it in front-end code, and rotate it if it leaks. For the rest of this tutorial we'll reference it as an environment variable rather than pasting it inline.
# Keep secrets out of your shell history and source files.
export WA_TOKEN="EAAG...your-temporary-token..."
export WA_PHONE_ID="123456789012345" # Phone Number ID from Step 1
export WA_TO="15551234567" # your test recipient, E.164 without +Step 3: Send your first message (the hello_world template)
Your very first send must be a template, not a free-form text. That's because of the 24-hour window (explained below): until the user has messaged you, the only thing you're allowed to send is a pre-approved template. Meta ships a ready-made one called hello_world in language en_US, so you can send immediately without waiting for approval.
The endpoint is POST to graph.facebook.com with your pinned Graph API version and Phone Number ID. Here it is with curl:
curl -X POST "https://graph.facebook.com/v25.0/${WA_PHONE_ID}/messages" \
-H "Authorization: Bearer ${WA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "'"${WA_TO}"'",
"type": "template",
"template": {
"name": "hello_world",
"language": { "code": "en_US" }
}
}'A successful response returns a JSON object with a messages array containing a wamid (the WhatsApp message ID). Check your phone - the hello_world template should arrive within seconds. If it doesn't, jump to the troubleshooting section.
Now the same call in Node.js. This uses the built-in fetch available in Node 18+, so there are no dependencies to install:
const GRAPH_VERSION = "v25.0";
const PHONE_ID = process.env.WA_PHONE_ID;
const TOKEN = process.env.WA_TOKEN;
async function sendTemplate(to) {
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${PHONE_ID}/messages`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to,
type: "template",
template: { name: "hello_world", language: { code: "en_US" } },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`WA error ${res.status}: ${JSON.stringify(data)}`);
console.log("Sent. Message ID:", data.messages?.[0]?.id);
return data;
}
sendTemplate(process.env.WA_TO).catch(console.error);Step 4: Phone number format (E.164)
The 'to' field must be in E.164 format: country code plus the national number, digits only - no plus sign, no spaces, no dashes or parentheses. A US number written +1 (555) 123-4567 becomes 15551234567. A UK number 07700 900123 becomes 447700900123.
Getting this wrong is the single most common reason a first message 'succeeds' but never arrives. If you're sending to many numbers, normalize them before the API call - and a lookup like our number validator can confirm which numbers are actually on WhatsApp before you send, which protects your quality rating. See also how to check if a number is on WhatsApp.
Step 5: The 24-hour window and free-form text
Here's the rule that confuses every beginner. Every time a user sends you a message, a 24-hour customer service window (CSW) opens. While that window is open, you can reply with free-form (non-template) messages of any type - plain text, images, documents - at no cost. Once the window closes, free-form messages are blocked: the only way to re-engage the user is with an approved template.
So to test a free-form text, first message your business number from your test phone. That opens the window. Now this call will go through:
curl -X POST "https://graph.facebook.com/v25.0/${WA_PHONE_ID}/messages" \
-H "Authorization: Bearer ${WA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "'"${WA_TO}"'",
"type": "text",
"text": { "body": "Thanks for reaching out! How can we help?" }
}'Try the same call to a number that has NOT messaged you and it fails - Meta rejects free-form messages with no open window. That's the system working as designed: it prevents businesses from cold-texting people.
Billing follows the same logic. On July 1, 2025 Meta switched from conversation-based pricing to per-message pricing (PMP). You're now charged per delivered template message, priced by the template's category and the recipient's country code. Non-template messages are free. User-initiated service conversations are now free and unlimited (the old 1,000-free-per-month allowance is gone), and utility templates sent inside an open customer service window are free too - they're only charged when sent outside the window. There's also a free entry-point window: 72 hours of free messaging when a user starts the chat via a Click-to-WhatsApp ad or a Facebook/Instagram call-to-action.
| Scenario | Message type allowed | Charged? |
|---|---|---|
| Inside 24h CSW (user messaged you) | Free-form OR template | Free-form free; utility template free, others charged |
| Outside the window | Approved template only | Charged per delivered template (by category + country) |
| 72h free entry point (ad / CTA click) | Free-form OR template | Free during the 72h window |
| No window, free-form text | Blocked | N/A - request fails |
Step 6: Receive messages with webhooks
To actually have a conversation - and to know that 24-hour window has opened - you need to receive inbound messages and status updates. That's what webhooks are for. In the App Dashboard go to WhatsApp > Configuration, set a Callback URL (a public HTTPS endpoint), set a Verify Token (any string you invent), then subscribe to the messages field.
Meta first verifies your endpoint with a GET handshake: it calls your URL with hub.mode=subscribe, hub.verify_token and hub.challenge query params. You compare the token to yours and, if it matches, echo back the hub.challenge value with HTTP 200. After that, inbound events arrive as POST requests.
Security matters here: every POST is signed with an X-Hub-Signature-256 header containing 'sha256=' followed by an HMAC-SHA256 of the raw request body keyed by your App Secret. You must verify it against the raw, pre-JSON-parse body using a constant-time comparison. Here's a complete Express handler that does both:
import express from "express";
import crypto from "crypto";
const app = express();
const VERIFY_TOKEN = process.env.WA_VERIFY_TOKEN;
const APP_SECRET = process.env.WA_APP_SECRET;
// Capture the RAW body so we can verify the signature against it.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
// 1) Verification handshake (GET)
app.get("/webhook", (req, res) => {
const mode = req.query["hub.mode"];
const token = req.query["hub.verify_token"];
const challenge = req.query["hub.challenge"];
if (mode === "subscribe" && token === VERIFY_TOKEN) {
return res.status(200).send(challenge); // echo it back
}
return res.sendStatus(403);
});
// 2) Event delivery (POST) with signature check
app.post("/webhook", (req, res) => {
const sig = req.get("X-Hub-Signature-256") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", APP_SECRET)
.update(req.rawBody)
.digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const msg = req.body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
if (msg) console.log("Inbound from", msg.from, "->", msg.text?.body);
res.sendStatus(200); // always 200 quickly, or Meta will retry
});
app.listen(3000, () => console.log("Webhook listening on :3000"));Step 7: Creating and getting templates approved
hello_world gets you started, but real outbound messaging needs your own templates. You create them in the WhatsApp Manager (under your WABA), and each must be assigned a category. There are three billable categories - MARKETING, UTILITY, and AUTHENTICATION - alongside the free-form service concept for in-window replies. Review usually takes from a few minutes to 24 hours.
| Category | Use for | Example |
|---|---|---|
| MARKETING | Promotions, announcements, re-engagement | "Your favorite item is back in stock - 20% off this week." |
| UTILITY | Transaction follow-ups tied to a specific action | "Your order #4821 has shipped and arrives Tuesday." |
| AUTHENTICATION | One-time passcodes and verification | "Your verification code is 458210." |
Be honest with categories, because Meta enforces them. Under auto-recategorization (in effect since around April 2025), if you label a template UTILITY but Meta judges its content to be MARKETING, Meta overrides the category - and since April 16, 2025 it can do so for flagged senders without 24 hours' advance notice. Since category drives price, a mislabeled template can cost more than you budgeted. If you disagree with a categorization you can request a review within 60 days.
If you're specifically building OTP flows, the AUTHENTICATION category has its own optimized templates and rules - our WhatsApp OTP verification API guide covers them in detail.
Going to production: tokens, limits and compliance
The test setup is great for learning but cannot go live. Here's what changes when you're ready for real traffic.
- Use your own number. Add and verify a real business phone number; the free test number cannot go to production. You'll also need to complete business verification for your portfolio.
- Respect opt-in. Business-initiated messages require prior user opt-in. Unsolicited messaging tanks your number's quality rating and risks bans - the fastest way to lose API access.
- Mind the tiered messaging limits. A brand-new, unverified portfolio starts at just 250 unique recipients per 24 hours. The ladder then climbs 250 -> 1,000 -> 10K -> 100K -> unlimited as your quality rating and volume hold up. As of October 7, 2025 these limits apply at the business portfolio level, not per phone number.
- Watch template quality. Rejected or paused templates and low quality ratings throttle your sending, so keep content relevant and let users opt out.
If avoiding throttling and bans is top of mind, our guide on how to avoid a WhatsApp ban goes deeper on quality ratings and sender hygiene. And if you're weighing the Cloud API against running your own infrastructure, the Cloud API vs unofficial comparison lays out the tradeoffs.
Next steps and tooling
You've now sent a template and a free-form message, received inbound events securely, and learned the rules that govern both. A few things worth doing next:
- Import Meta's official Postman collection ('WhatsApp Business Platform', publisher: meta) to explore media, interactive and reaction messages without writing code.
- Build a simple echo bot: when a webhook delivers an inbound text, reply within the open window. That single loop is the foundation of any chatbot.
- Validate phone numbers before sends so you only message numbers that are actually on WhatsApp - this keeps your quality rating healthy and your template spend efficient.
From here, the natural progression is turning these primitives into a real assistant - our guide on how to build a WhatsApp bot picks up where this tutorial leaves off.
Frequently asked questions
Filter out numbers that aren't on WhatsApp, fetch public profile details, and detect business accounts via a simple hosted API - so your template charges and quality rating both stay healthy.
Explore the WhatsApp Profile APISources & further reading
- Meta - Get Started (WhatsApp Cloud API)
- Meta - Send Messages guide
- Meta - Set up Webhooks
- Meta - Graph API Changelog (current version)
- Meta - Updates to WhatsApp pricing (per-message, July 1, 2025)
- Meta - Messaging Limits (tiers, starting at 250)
- Meta - Upcoming messaging limits changes (portfolio-level, Oct 7, 2025)
- Meta - Template categorization (auto-recategorization, April 2025)
- Meta - Access tokens (temporary vs System User permanent)