WhatsApp Cloud API
Send WhatsApp text and template messages through Meta's Cloud API, with the 24-hour window and missing templates named when they refuse a message, answer the webhook's verify handshake, check X-Hub-Signature-256, and flatten webhook bodies into the messages and statuses they carry.
Version 1.0.0 · 10 functions · 5 crates
Functions
What use whatsapp.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use whatsapp_send, whatsapp_text from whatsapp (Quillo 0.6).
whatsapp_send(to: str, message: any, options: any) → anyor an errorSends any message: message holds type and its part ({ type: "image", image: { link } }, "document", "location", "interactive"...), with messaging_product added. Options: token (else WHATSAPP_TOKEN), phone_number_id (else WHATSAPP_PHONE_NUMBER_ID), base_url (else WHATSAPP_API_URL), version (else WHATSAPP_API_VERSION), reply_to (a message id). Gives { id, wa_id, status }.
whatsapp_text(to: str, text: str, options: any) → anyor an errorA text message of up to 4096 characters. It reaches someone only within 24 hours of their last message; outside that send a template. options add preview_url (true shows a link's preview) to whatsapp_send's.
whatsapp_template(to: str, name: str, language: str, components: list, options: any) → anyor an errorA template message, the one way to start a conversation: name and language ("en_US") as approved in WhatsApp Manager, components from whatsapp_body and whatsapp_button or as the API documents them ([] when it has no variables).
whatsapp_body(values: list) → anyThe body component of a template, filling {{1}}, {{2}}... with values.
whatsapp_header_image(link: str) → anyThe header component of a template, with an image at link.
whatsapp_button(index: int, sub_type: str, value: str) → anyor an errorA template's button component: index 0 onward, sub_type "url" (value fills the URL's {{1}}) or "quick_reply" (value is the payload that comes back).
whatsapp_mark_read(message_id: str, options: any) → boolor an errorMarks a received message as read, which shows blue ticks.
whatsapp_verify(query: map, verify_token: str) → stror an errorAnswers Meta's webhook check: query is the GET's, with hub.mode, hub.verify_token, hub.challenge. Gives the challenge, to send back as plain text, or an error for a 403.
whatsapp_signature_ok(body: str, header: str, app_secret: str) → boolWhether a webhook POST is really Meta's: the raw body and its X-Hub-Signature-256 header, an HMAC-SHA256 with the app secret (else WHATSAPP_APP_SECRET), compared in constant time.
whatsapp_read_webhook(body: str) → anyor an errorWhat a webhook body holds, flattened: { messages: [{ id, from, name, timestamp, type, text, button, phone_number_id, raw }], statuses: [{ id, status, recipient, timestamp, errors, phone_number_id }] }. A body can carry several of each; statuses come out of order, so judge by timestamp.
Get it
$ quillo install whatsapp # whatsapp.qwer into this folder$ quillo install whatsapp --example # and its example, whatsapp.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/whatsapp.qwer$ curl -O https://quillolang.org/plugins/files/whatsapp.qweThen, in your program, all of it or only what you call:
use whatsapp.qweruse whatsapp_send, whatsapp_text from whatsapp // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"hmac = "0.12"sha2 = "0.10"hex = "0.4"serde_json = { version = "1", features = ["preserve_order"] }Example
// WhatsApp through the Cloud API: a text, a template with its variables, and the webhook: Meta's// verify handshake, the signature checked, and incoming messages answered and marked read.//// WHATSAPP_TOKEN=... WHATSAPP_PHONE_NUMBER_ID=... WHATSAPP_APP_SECRET=... WHATSAPP_VERIFY_TOKEN=... quillo run whatsapp.qwe// then open http://localhost:8080/text, /template, /hook-check and /failures//// Point the app's webhook at /whatsapp/hook. WHATSAPP_API_URL reaches a test server.use "whatsapp.qwer"let TO = "+1 (555) 123-4567"let VERIFY = env("WHATSAPP_VERIFY_TOKEN", "")route GET /text { let r = whatsapp_text(TO, "Your order has shipped", { preview_url: false })? { id: r.id, to: r.wa_id }}route GET /template { let components = [ whatsapp_header_image("https://example.com/box.png"), whatsapp_body(["Ada", "1001"]), whatsapp_button(0, "url", "track/1001")?, ] let r = whatsapp_template(TO, "order_update", "en_US", components, {})? { id: r.id, status: r.status, plain: whatsapp_template(TO, "hello_world", "en_US", [], {})?.status }}route GET /image { let r = whatsapp_send(TO, { type: "image", image: { link: "https://example.com/box.png", caption: "Your box" } }, {})? { id: r.id }}route GET /failures = { window: whatsapp_text("15550000001", "Hi", {}).error, template: whatsapp_template(TO, "no_such_template", "en_US", [], {}).error, bad_token: whatsapp_text(TO, "Hi", { token: "expired" }).error, bad_number: whatsapp_text("0123", "Hi", {}).error, retried: whatsapp_text("15550000002", "Hi", {})?.wa_id,}// Meta calls this once when the webhook is set.route GET /whatsapp/hook { let challenge = whatsapp_verify(request.query, VERIFY) if is_err(challenge) { reply 403 challenge.error } challenge}route POST /whatsapp/hook { if !whatsapp_signature_ok(request.body, request.headers["x-hub-signature-256"] ?? "", "") { reply 401 "bad signature" } let hook = whatsapp_read_webhook(request.body)? for m in hook.messages { whatsapp_mark_read(m.id, {})? whatsapp_text(m.from, `You said: ${m.text}`, { reply_to: m.id })? } { messages: len(hook.messages), statuses: len(hook.statuses) }}// Signature vectors made outside the plugin, and a body shaped as Meta sends it.let sample = "{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"1\",\"changes\":[{\"field\":\"messages\",\"value\":{\"metadata\":{\"phone_number_id\":\"106\"},\"contacts\":[{\"wa_id\":\"15551234567\",\"profile\":{\"name\":\"Ada\"}}],\"messages\":[{\"id\":\"wamid.A\",\"from\":\"15551234567\",\"timestamp\":\"1790000000\",\"type\":\"text\",\"text\":{\"body\":\"Hello\"}},{\"id\":\"wamid.B\",\"from\":\"15551234567\",\"timestamp\":\"1790000001\",\"type\":\"button\",\"button\":{\"text\":\"Yes\",\"payload\":\"confirm\"}}],\"statuses\":[{\"id\":\"wamid.C\",\"status\":\"delivered\",\"recipient_id\":\"15551234567\",\"timestamp\":\"1790000002\"}]}}]}]}"route GET /hook-check { let hook = whatsapp_read_webhook(sample)? let body = "{\"object\":\"whatsapp_business_account\"}" { good: whatsapp_signature_ok(body, "sha256=0bf7374433906636bed1653bc7c64affb42fa5da9a39e251ab95c2e3a9c06923", "app-secret"), tampered: whatsapp_signature_ok(body + " ", "sha256=0bf7374433906636bed1653bc7c64affb42fa5da9a39e251ab95c2e3a9c06923", "app-secret"), verify_ok: whatsapp_verify({ "hub.mode": "subscribe", "hub.verify_token": "tok", "hub.challenge": "1158201444" }, "tok")?, verify_bad: whatsapp_verify({ "hub.mode": "subscribe", "hub.verify_token": "guess", "hub.challenge": "1" }, "tok").error, from: hook.messages[0].name, said: map(hook.messages, (m) => m.text), button: hook.messages[1].button, status: hook.statuses[0].status, }}$ quillo run whatsapp.qwe$ curl 'localhost:8080/text'$ curl 'localhost:8080/template'$ curl 'localhost:8080/image'$ curl 'localhost:8080/failures'$ curl 'localhost:8080/whatsapp/hook?hub.mode=subscribe&hub.verify_token=verify-me&hub.challenge=1158201444'$ curl 'localhost:8080/hook-check'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! The WhatsApp Cloud API: text and template messages, read receipts, the webhook's verify handshake and X-Hub-Signature-256 check, and webhook bodies flattened into the messages and statuses they hold.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! hmac = "0.12"//! sha2 = "0.10"//! hex = "0.4"//! serde_json = { version = "1", features = ["preserve_order"] }use hmac::{Hmac, Mac};use serde_json::{json, Value};use sha2::Sha256;use std::time::Duration;fn opt(o: &Value, k: &str, var: &str) -> Option<String> { o.get(k).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string).or_else(|| std::env::var(var).ok().filter(|v| !v.is_empty()))}// Numbers are sent as digits with the country code, no + or spaces.fn digits(to: &str) -> Result<String, String> { let d: String = to.chars().filter(|c| c.is_ascii_digit()).collect(); if d.len() < 8 || d.len() > 15 || d.starts_with('0') { return Err(format!("{:?} is not a number with its country code, like 15551234567", to)); } Ok(d)}fn error_text(status: u16, body: &str) -> (i64, String) { let v: Value = serde_json::from_str(body).unwrap_or(Value::Null); let e = &v["error"]; let code = e["code"].as_i64().unwrap_or(0); let msg = e["message"].as_str().map(str::to_string).unwrap_or_else(|| body.trim().chars().take(300).collect()); let details = e.pointer("/error_data/details").and_then(|d| d.as_str()).map(|d| format!(": {}", d)).unwrap_or_default(); let hint = match code { 131047 => " (over 24 hours since the customer last wrote: send a template)", 131026 => " (the number cannot be reached on WhatsApp)", 131030 => " (in test mode only numbers on the allowed list can be sent to)", 132000 => " (the template's parameters do not match it)", 132001 => " (no approved template has this name and language)", 190 => " (the access token is wrong or has expired)", 100 => " (a parameter is wrong)", _ => "", }; (code, format!("WhatsApp {} (code {}): {}{}{}", status, code, msg, details, hint))}fn post(path: &str, body: &Value, o: &Value) -> Result<Value, String> { let token = opt(o, "token", "WHATSAPP_TOKEN").ok_or("no access token: set WHATSAPP_TOKEN, or give the option token")?; let phone = opt(o, "phone_number_id", "WHATSAPP_PHONE_NUMBER_ID").ok_or("no sender: set WHATSAPP_PHONE_NUMBER_ID, or give the option phone_number_id")?; let base = opt(o, "base_url", "WHATSAPP_API_URL").unwrap_or_else(|| "https://graph.facebook.com".into()); let version = opt(o, "version", "WHATSAPP_API_VERSION").unwrap_or_else(|| "v23.0".into()); let url = format!("{}/{}/{}/{}", base.trim_end_matches('/'), version, phone, path); let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build(); let mut tries = 0; loop { let res = agent.post(&url).set("Authorization", &format!("Bearer {}", token)).set("Content-Type", "application/json").send_string(&body.to_string()); let (status, text) = match res { Ok(r) => { let t = r.into_string().map_err(|e| e.to_string())?; return serde_json::from_str(&t).map_err(|_| format!("WhatsApp's answer is not JSON: {:?}", t.chars().take(200).collect::<String>())); } Err(ureq::Error::Status(c, r)) => (c, r.into_string().unwrap_or_default()), Err(e) => return Err(format!("cannot reach WhatsApp at {}: {}", base, e)), }; let (code, msg) = error_text(status, &text); // 130429 is the number's message rate; 131056 too many to one person. Both pass in moments. if (status == 429 || code == 130429 || code == 131056 || code == 80007) && tries < 2 { std::thread::sleep(Duration::from_millis(500 * 4u64.pow(tries))); tries += 1; continue; } return Err(msg); }}/// Sends any message: `message` holds `type` and its part ({ type: "image", image: { link } }, "document", "location", "interactive"...), with messaging_product added. Options: token (else WHATSAPP_TOKEN), phone_number_id (else WHATSAPP_PHONE_NUMBER_ID), base_url (else WHATSAPP_API_URL), version (else WHATSAPP_API_VERSION), reply_to (a message id). Gives { id, wa_id, status }.pub fn whatsapp_send(to: &str, message: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { let mut body = if message.is_object() { message } else { return Err("the message is a map with a `type`".into()) }; if body.get("type").and_then(|t| t.as_str()).is_none() { return Err("the message needs a `type`".into()); } body["messaging_product"] = json!("whatsapp"); body["recipient_type"] = json!("individual"); body["to"] = json!(digits(to)?); if let Some(r) = options.get("reply_to").and_then(|r| r.as_str()) { body["context"] = json!({ "message_id": r }); } let r = post("messages", &body, &options)?; Ok(json!({ "id": r.pointer("/messages/0/id"), "wa_id": r.pointer("/contacts/0/wa_id"), "status": r.pointer("/messages/0/message_status") }))}/// A text message of up to 4096 characters. It reaches someone only within 24 hours of their last message; outside that send a template. `options` add preview_url (true shows a link's preview) to whatsapp_send's.pub fn whatsapp_text(to: &str, text: &str, options: serde_json::Value) -> Result<serde_json::Value, String> { let n = text.chars().count(); if n == 0 || n > 4096 { return Err(format!("a text message is 1 to 4096 characters, not {}", n)); } let preview = options.get("preview_url").and_then(|p| p.as_bool()).unwrap_or(false); whatsapp_send(to, json!({ "type": "text", "text": { "body": text, "preview_url": preview } }), options)}/// A template message, the one way to start a conversation: `name` and `language` ("en_US") as approved in WhatsApp Manager, `components` from whatsapp_body and whatsapp_button or as the API documents them ([] when it has no variables).pub fn whatsapp_template(to: &str, name: &str, language: &str, components: Vec<serde_json::Value>, options: serde_json::Value) -> Result<serde_json::Value, String> { let mut t = json!({ "name": name, "language": { "code": language } }); if !components.is_empty() { t["components"] = json!(components); } whatsapp_send(to, json!({ "type": "template", "template": t }), options)}fn text_params(values: Vec<String>) -> Vec<Value> { values.into_iter().map(|v| json!({ "type": "text", "text": v })).collect()}/// The body component of a template, filling {{1}}, {{2}}... with `values`.pub fn whatsapp_body(values: Vec<String>) -> serde_json::Value { json!({ "type": "body", "parameters": text_params(values) })}/// The header component of a template, with an image at `link`.pub fn whatsapp_header_image(link: &str) -> serde_json::Value { json!({ "type": "header", "parameters": [{ "type": "image", "image": { "link": link } }] })}/// A template's button component: `index` 0 onward, `sub_type` "url" (value fills the URL's {{1}}) or "quick_reply" (value is the payload that comes back).pub fn whatsapp_button(index: i64, sub_type: &str, value: &str) -> Result<serde_json::Value, String> { let p = match sub_type { "url" => json!({ "type": "text", "text": value }), "quick_reply" => json!({ "type": "payload", "payload": value }), o => return Err(format!("a button's sub_type is url or quick_reply, not {:?}", o)), }; Ok(json!({ "type": "button", "sub_type": sub_type, "index": index.to_string(), "parameters": [p] }))}/// Marks a received message as read, which shows blue ticks.pub fn whatsapp_mark_read(message_id: &str, options: serde_json::Value) -> Result<bool, String> { let r = post("messages", &json!({ "messaging_product": "whatsapp", "status": "read", "message_id": message_id }), &options)?; Ok(r["success"] == true)}/// Answers Meta's webhook check: `query` is the GET's, with hub.mode, hub.verify_token, hub.challenge. Gives the challenge, to send back as plain text, or an error for a 403.pub fn whatsapp_verify(query: std::collections::HashMap<String, serde_json::Value>, verify_token: &str) -> Result<String, String> { let q = |k: &str| query.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or(v.to_string())); if q("hub.mode").as_deref() != Some("subscribe") { return Err("hub.mode is not subscribe".into()); } let got = q("hub.verify_token").unwrap_or_default(); if verify_token.is_empty() || !same(&got, verify_token) { return Err("hub.verify_token does not match".into()); } q("hub.challenge").ok_or("no hub.challenge".to_string())}fn same(a: &str, b: &str) -> bool { a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0}fn signature(secret: &str, body: &str) -> String { let mut m = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key"); m.update(body.as_bytes()); format!("sha256={}", hex::encode(m.finalize().into_bytes()))}/// Whether a webhook POST is really Meta's: the raw `body` and its X-Hub-Signature-256 header, an HMAC-SHA256 with the app secret (else WHATSAPP_APP_SECRET), compared in constant time.pub fn whatsapp_signature_ok(body: &str, header: &str, app_secret: &str) -> bool { let secret = if app_secret.is_empty() { std::env::var("WHATSAPP_APP_SECRET").unwrap_or_default() } else { app_secret.to_string() }; !secret.is_empty() && same(&signature(&secret, body), &header.to_ascii_lowercase())}/// What a webhook body holds, flattened: { messages: [{ id, from, name, timestamp, type, text, button, phone_number_id, raw }], statuses: [{ id, status, recipient, timestamp, errors, phone_number_id }] }. A body can carry several of each; statuses come out of order, so judge by timestamp.pub fn whatsapp_read_webhook(body: &str) -> Result<serde_json::Value, String> { let v: Value = serde_json::from_str(body).map_err(|e| format!("the webhook's body is not JSON: {}", e))?; if v["object"] != "whatsapp_business_account" { return Err(format!("this is not a WhatsApp webhook: object is {}", v["object"])); } let (mut messages, mut statuses) = (vec![], vec![]); for entry in v["entry"].as_array().into_iter().flatten() { for change in entry["changes"].as_array().into_iter().flatten() { let val = &change["value"]; let phone = val.pointer("/metadata/phone_number_id").cloned().unwrap_or(Value::Null); let contacts = val["contacts"].as_array().cloned().unwrap_or_default(); for m in val["messages"].as_array().into_iter().flatten() { let name = contacts.iter().find(|c| c["wa_id"] == m["from"]).and_then(|c| c.pointer("/profile/name")).cloned().unwrap_or(Value::Null); let text = m.pointer("/text/body").or(m.pointer("/interactive/button_reply/title")).or(m.pointer("/interactive/list_reply/title")).or(m.pointer("/button/text")).or(m.pointer("/image/caption")).cloned().unwrap_or(Value::Null); let button = m.pointer("/button/payload").or(m.pointer("/interactive/button_reply/id")).or(m.pointer("/interactive/list_reply/id")).cloned().unwrap_or(Value::Null); messages.push(json!({ "id": m["id"], "from": m["from"], "name": name, "timestamp": m["timestamp"], "type": m["type"], "text": text, "button": button, "phone_number_id": phone, "raw": m })); } for s in val["statuses"].as_array().into_iter().flatten() { statuses.push(json!({ "id": s["id"], "status": s["status"], "recipient": s["recipient_id"], "timestamp": s["timestamp"], "errors": s.get("errors").cloned().unwrap_or(json!([])), "phone_number_id": phone })); } } } Ok(json!({ "messages": messages, "statuses": statuses }))}Related
telegram.qwer · 1.0.0A Telegram bot through the Bot API: messages split at 4096 characters, photos by URL or upload, inline keyboards checked before they are sent, updates by webhook or long polling read into one shape, the webhook's secret token compared, and the Login Widget's hash verified.4 views · 8 downloadsCloudinary cloudinary.qwer · 1.0.0Cloudinary without an SDK: signed uploads from a URL or a file, the signature a browser needs for a direct upload, delivery URLs with transformations and URL signatures, webhook and upload signatures checked, and assets listed by cursor.3 views · 8 downloadsDiscord discord.qwer · 1.0.0Messages to Discord channels through webhooks: embeds with color, fields, images and timestamps built and checked against Discord's limits, mentions off unless asked for, threads, and the rate limit's wait reported.7 views · 8 downloadsHubSpot hubspot.qwer · 1.0.0Keep HubSpot's CRM in step with a program: contacts and deals made, read and linked, searches and lists followed through every page, batch upserts in hundreds with their partial failures read back, and webhook v3 signatures checked with HubSpot's odd URI decoding.1 view · 6 downloadsLinear linear.qwer · 1.0.0Work with Linear through its GraphQL API: issues opened, changed and moved to a state by name, comments, lists walked by cursor, the rate limit it answers as an HTTP 400 waited out, and webhooks checked by signature and timestamp.1 view · 7 downloadsMicrosoft Teams teams.qwer · 1.0.0Post to Microsoft Teams channels through incoming webhooks: Adaptive Cards built from headings, facts, columns, images and link buttons and checked before they go, sent to Workflows or classic connector URLs, with the failures Teams reports as a 200 read as errors and 429s waited out.3 views · 6 downloadsThread 0 posts
On WhatsApp Cloud API: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.