Services

Cloudinary

Cloudinary 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.

Version 1.0.0 · 10 functions · 6 crates

#images#uploads#cdn#webhooks#signing

Functions

What use cloudinary.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use cloudinary_signature, cloudinary_upload from cloudinary (Quillo 0.6).

cloudinary_signature(params: any) → stror an error

A signature over parameters as Cloudinary wants it: those it signs, sorted, joined as a=1&b=2, the secret added at the end, and hashed. file, cloud_name, resource_type, api_key and empty values are left out; lists are joined by commas.

cloudinary_upload(file: str, options: any) → anyor an error

Uploads file, which is an http(s) address, a data: URI or a path on this machine. options are Cloudinary's upload parameters: public_id, folder, tags, overwrite, eager, resource_type ("image", "video", "raw", "auto"). Gives Cloudinary's answer: public_id, version, secure_url, width, height, format, bytes, signature.

cloudinary_upload_params(params: any) → anyor an error

What a browser needs to upload straight to Cloudinary, after this server has decided it may: { url, api_key, timestamp, signature, fields }, fields being params as signed. They must all be sent unchanged, with file.

cloudinary_destroy(public_id: str, options: any) → boolor an error

Deletes an asset. options: resource_type, invalidate (true also clears the CDN). True when it was deleted, false when there was no such asset.

cloudinary_assets(prefix: str, max: int, cursor: str) → anyor an error

One page of assets through the Admin API, in folder prefix ("" for all), up to max (500 at most), from cursor ("" at first). Gives { assets, next }.

cloudinary_transformation(transformation: any) → stror an error

A transformation as URL text: a map becomes c_fill,h_200,w_300, parameters sorted as the SDKs do, a list becomes steps joined by /, and a text is used as it is.

cloudinary_url(public_id: str, options: any) → stror an error

The delivery URL of an asset. options: transformation (as for cloudinary_transformation), format ("webp"), version, resource_type, type ("upload", "private", "authenticated"), sign (true adds the s--...-- signature, which an authenticated asset or a strict transformation needs). No request is made.

cloudinary_webhook(body: str, timestamp: str, signature: str, max_age: int) → anyor an error

Checks a webhook from Cloudinary: body the raw body, timestamp and signature the X-Cld-Timestamp and X-Cld-Signature headers. Refused when not signed with this secret, or signed more than max_age seconds ago (7200 is what Cloudinary suggests; 0 skips that). Gives the notification.

cloudinary_webhook_sign(body: str, timestamp: str) → stror an error

Signs a webhook body as Cloudinary does, for tests: the X-Cld-Signature value.

cloudinary_verify_upload(public_id: str, version: str, signature: str) → boolor an error

Checks the signature of an upload's answer, for when a browser reports an upload it made itself: true when public_id and version are what Cloudinary signed.

Get it

$ quillo install cloudinary               # cloudinary.qwer into this folder$ quillo install cloudinary --example     # and its example, cloudinary.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/cloudinary.qwer$ curl -O https://quillolang.org/plugins/files/cloudinary.qwe

Then, in your program, all of it or only what you call:

main.qwe
use cloudinary.qweruse cloudinary_signature, cloudinary_upload from cloudinary      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ureq = "2"base64 = "0.22"sha1 = "0.10"sha2 = "0.10"hex = "0.4"serde_json = { version = "1", features = ["preserve_order"] }

Example

cloudinary.qwe
// Images on Cloudinary from a server: signed uploads, delivery URLs with transformations, the// signature a browser needs for a direct upload, and webhooks checked.////   CLOUDINARY_CLOUD_NAME=demo CLOUDINARY_API_KEY=... CLOUDINARY_API_SECRET=... quillo run cloudinary.qwe//   then open /upload, /urls, /browser, /assets, /webhook and /brokenuse "cloudinary.qwer"route GET /upload {    let remote = cloudinary_upload("https://example.com/cat.jpg", { public_id: "cats/first", tags: ["cat", "pet"], overwrite: true })?    let local = cloudinary_upload("cloudinary.qwe", { folder: "docs", resource_type: "raw" })?    {        id: remote.public_id,        url: remote.secure_url,        width: remote.width,        checked: cloudinary_verify_upload(remote.public_id, str(remote.version), remote.signature)?,        forged: cloudinary_verify_upload("cats/other", str(remote.version), remote.signature)?,        local_bytes: local.bytes > 100,        local_id: local.public_id,    }}// The fields of Cloudinary's documented example: `file` and `api_key` are not signed, and the others// are sorted. Its result is checked against the example's method with this server's secret.route GET /signature = cloudinary_signature({ public_id: "sample_image", timestamp: 1315060510, eager: "w_400,h_300,c_pad|w_260,h_200,c_crop", file: "left out", api_key: "left out" })?route GET /urls = {    plain: cloudinary_url("cats/first", { format: "jpg" })?,    sized: cloudinary_url("cats/first", { transformation: { width: 300, height: 200, crop: "fill", gravity: "auto", quality: "auto", fetch_format: "auto" }, version: 1312461204, format: "jpg" })?,    chained: cloudinary_url("cats/first", { transformation: [{ width: 300, crop: "scale" }, { angle: 10 }, "e_sepia"] })?,    signed: cloudinary_url("sample.jpg", { type: "authenticated", sign: true, transformation: { width: 300, height: 300, crop: "fill" } })?,    spaces: cloudinary_url("my cat", { format: "png" })?,}// A page asks here, then posts the file straight to Cloudinary with these fields.route GET /browser {    let p = cloudinary_upload_params({ folder: "user-7", tags: "avatar" })?    { url: p.url, key: p.api_key, fields: keys(p.fields), matches: p.signature == cloudinary_signature(p.fields)? }}route GET /assets {    let first = cloudinary_assets("cats", 1, "")?    let second = cloudinary_assets("cats", 1, first.next)?    { first: first.assets | map((a) => a.public_id), second: second.assets | map((a) => a.public_id), end: second.next }}// The endpoint a Cloudinary notification URL points at.route POST /cloudinary/webhook {    let n = cloudinary_webhook(request.body, request.headers["x-cld-timestamp"] ?? "", request.headers["x-cld-signature"] ?? "", 7200)    if is_err(n) { reply 401 n.error }    { kind: n.notification_type }}route GET /webhook {    let body = json.stringify({ notification_type: "upload", public_id: "cats/first" })    let at = str(int(now() / 1000))    let sig = cloudinary_webhook_sign(body, at)?    {        good: cloudinary_webhook(body, at, sig, 7200)?.notification_type,        changed: cloudinary_webhook(body + " ", at, sig, 7200).error,        late: cloudinary_webhook(body, "1000000000", cloudinary_webhook_sign(body, "1000000000")?, 7200).error,    }}route GET /broken {    let gone = cloudinary_destroy("cats/first", { invalidate: true })?    {        destroyed: gone,        again: cloudinary_destroy("cats/first", null)?,        bad_file: cloudinary_upload("https://example.com/not-an-image.txt", null).error,        no_file: cloudinary_upload("/no/such/file.png", null).error,        bad_step: cloudinary_url("x", { transformation: { wobble: 1 } }).error,    }}
$ quillo run cloudinary.qwe$ curl 'localhost:8080/upload'$ curl 'localhost:8080/signature'$ curl 'localhost:8080/urls'$ curl 'localhost:8080/browser'$ curl 'localhost:8080/assets'$ curl 'localhost:8080/webhook'$ curl 'localhost:8080/broken'

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

cloudinary.qwer
//! Cloudinary without an SDK: signed uploads from a URL, data or a file, the signed parameters a browser needs to upload directly, delivery URLs with transformations and URL signatures, upload and webhook signatures checked, assets listed by cursor and destroyed.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! base64 = "0.22"//! sha1 = "0.10"//! sha2 = "0.10"//! hex = "0.4"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::Engine;use serde_json::{json, Map, Value};use sha1::{Digest, Sha1};use std::time::Duration;// CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET come from the console.// CLOUDINARY_API_URL is the upload and admin API (https://api.cloudinary.com when not set),// CLOUDINARY_DELIVERY_URL where files are served from (https://res.cloudinary.com when not set).// CLOUDINARY_SIGNATURE_ALGORITHM is sha1 or sha256, as the product environment is set.fn env(k: &str) -> Option<String> {    std::env::var(k).ok().filter(|v| !v.is_empty())}fn need(k: &str) -> Result<String, String> {    env(k).ok_or(format!("{} is not set", k))}fn api() -> String {    env("CLOUDINARY_API_URL").unwrap_or_else(|| "https://api.cloudinary.com".into()).trim_end_matches('/').to_string()}fn now() -> i64 {    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}fn digest_hex(text: &str) -> String {    if env("CLOUDINARY_SIGNATURE_ALGORITHM").map_or(false, |a| a.eq_ignore_ascii_case("sha256")) {        hex::encode(sha2::Sha256::digest(text.as_bytes()))    } else {        hex::encode(Sha1::digest(text.as_bytes()))    }}fn param_text(v: &Value) -> Option<String> {    match v {        Value::Null => None,        Value::String(s) if s.is_empty() => None,        Value::String(s) => Some(s.clone()),        Value::Array(a) => Some(a.iter().map(|x| x.as_str().map_or(x.to_string(), str::to_string)).collect::<Vec<_>>().join(",")),        o => Some(o.to_string()),    }}/// A signature over parameters as Cloudinary wants it: those it signs, sorted, joined as `a=1&b=2`, the secret added at the end, and hashed. `file`, `cloud_name`, `resource_type`, `api_key` and empty values are left out; lists are joined by commas.pub fn cloudinary_signature(params: serde_json::Value) -> Result<String, String> {    let secret = need("CLOUDINARY_API_SECRET")?;    let mut pairs: Vec<(String, String)> = vec![];    for (k, v) in params.as_object().ok_or("the parameters are a map")? {        if ["file", "cloud_name", "resource_type", "api_key", "signature"].contains(&k.as_str()) {            continue;        }        if let Some(t) = param_text(v) {            pairs.push((k.clone(), t));        }    }    pairs.sort();    let text = pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&");    Ok(digest_hex(&format!("{}{}", text, secret)))}fn multipart(fields: &[(String, String)], file: Option<(&str, &[u8])>) -> (String, Vec<u8>) {    let boundary = format!("quillo{:x}{:x}", now(), std::process::id());    let mut b = Vec::new();    for (k, v) in fields {        b.extend_from_slice(format!("--{}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", boundary, k, v).as_bytes());    }    if let Some((name, bytes)) = file {        let safe: String = name.chars().filter(|c| *c != '"' && *c != '\r' && *c != '\n').collect();        b.extend_from_slice(format!("--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", boundary, safe).as_bytes());        b.extend_from_slice(bytes);        b.extend_from_slice(b"\r\n");    }    b.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());    (format!("multipart/form-data; boundary={}", boundary), b)}fn read_answer(res: Result<ureq::Response, ureq::Error>) -> Result<Value, String> {    let (status, text) = match res {        Ok(r) => (r.status(), r.into_string().map_err(|e| e.to_string())?),        Err(ureq::Error::Status(c, r)) => (c, r.into_string().unwrap_or_default()),        Err(e) => return Err(format!("cannot reach Cloudinary at {}: {}", api(), e)),    };    let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);    if status >= 300 {        let m = v["error"]["message"].as_str().map(str::to_string).unwrap_or(text.chars().take(300).collect());        return Err(format!("Cloudinary answered {}: {}", status, m));    }    Ok(v)}fn resource_of(o: &Value) -> String {    o["resource_type"].as_str().unwrap_or("image").to_string()}/// The parameters of a signed call: what was given, with `timestamp`, `api_key` and `signature`.fn signed(params: &Value) -> Result<Vec<(String, String)>, String> {    let mut m = params.as_object().cloned().unwrap_or_default();    m.remove("resource_type");    m.entry("timestamp".to_string()).or_insert(json!(now()));    let sig = cloudinary_signature(Value::Object(m.clone()))?;    let mut out: Vec<(String, String)> = m.iter().filter_map(|(k, v)| param_text(v).map(|t| (k.clone(), t))).collect();    out.push(("api_key".to_string(), need("CLOUDINARY_API_KEY")?));    out.push(("signature".to_string(), sig));    Ok(out)}/// Uploads `file`, which is an http(s) address, a `data:` URI or a path on this machine. `options` are Cloudinary's upload parameters: `public_id`, `folder`, `tags`, `overwrite`, `eager`, `resource_type` ("image", "video", "raw", "auto"). Gives Cloudinary's answer: `public_id`, `version`, `secure_url`, `width`, `height`, `format`, `bytes`, `signature`.pub fn cloudinary_upload(file: &str, options: serde_json::Value) -> Result<Value, String> {    let cloud = need("CLOUDINARY_CLOUD_NAME")?;    let url = format!("{}/v1_1/{}/{}/upload", api(), cloud, resource_of(&options));    let mut fields = signed(&if options.is_object() { options } else { json!({}) })?;    let is_remote = ["http://", "https://", "data:", "s3://", "gs://"].iter().any(|p| file.starts_with(p));    let bytes;    let part = if is_remote {        fields.push(("file".to_string(), file.to_string()));        None    } else {        bytes = std::fs::read(file).map_err(|e| format!("{}: {}", file, e))?;        Some((std::path::Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or("upload"), bytes.as_slice()))    };    let (kind, body) = multipart(&fields, part);    read_answer(ureq::post(&url).timeout(Duration::from_secs(300)).set("Content-Type", &kind).send_bytes(&body))}/// What a browser needs to upload straight to Cloudinary, after this server has decided it may: `{ url, api_key, timestamp, signature, fields }`, `fields` being `params` as signed. They must all be sent unchanged, with `file`.pub fn cloudinary_upload_params(params: serde_json::Value) -> Result<Value, String> {    let cloud = need("CLOUDINARY_CLOUD_NAME")?;    let url = format!("{}/v1_1/{}/{}/upload", api(), cloud, resource_of(&params));    let t = now();    let mut m = params.as_object().cloned().unwrap_or_default();    m.remove("resource_type");    m.insert("timestamp".into(), json!(t));    let sig = cloudinary_signature(Value::Object(m.clone()))?;    Ok(json!({ "url": url, "api_key": need("CLOUDINARY_API_KEY")?, "timestamp": t, "signature": sig, "fields": m }))}/// Deletes an asset. `options`: `resource_type`, `invalidate` (true also clears the CDN). True when it was deleted, false when there was no such asset.pub fn cloudinary_destroy(public_id: &str, options: serde_json::Value) -> Result<bool, String> {    let cloud = need("CLOUDINARY_CLOUD_NAME")?;    let mut p = json!({ "public_id": public_id });    if options["invalidate"] == true {        p["invalidate"] = json!(true);    }    let url = format!("{}/v1_1/{}/{}/destroy", api(), cloud, resource_of(&options));    let (kind, body) = multipart(&signed(&p)?, None);    let r = read_answer(ureq::post(&url).timeout(Duration::from_secs(60)).set("Content-Type", &kind).send_bytes(&body))?;    match r["result"].as_str() {        Some("ok") => Ok(true),        Some("not found") => Ok(false),        o => Err(format!("Cloudinary answered {:?}", o)),    }}/// One page of assets through the Admin API, in `folder` prefix ("" for all), up to `max` (500 at most), from `cursor` ("" at first). Gives `{ assets, next }`.pub fn cloudinary_assets(prefix: &str, max: i64, cursor: &str) -> Result<Value, String> {    let cloud = need("CLOUDINARY_CLOUD_NAME")?;    let (key, secret) = (need("CLOUDINARY_API_KEY")?, need("CLOUDINARY_API_SECRET")?);    let mut req = ureq::get(&format!("{}/v1_1/{}/resources/image/upload", api(), cloud)).timeout(Duration::from_secs(60)).query("max_results", &max.clamp(1, 500).to_string());    if !prefix.is_empty() {        req = req.query("prefix", prefix);    }    if !cursor.is_empty() {        req = req.query("next_cursor", cursor);    }    let auth = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", key, secret));    let r = read_answer(req.set("Authorization", &format!("Basic {}", auth)).call())?;    Ok(json!({ "assets": r["resources"], "next": r["next_cursor"] }))}const SHORT: &[(&str, &str)] = &[    ("angle", "a"), ("aspect_ratio", "ar"), ("background", "b"), ("border", "bo"), ("crop", "c"), ("color", "co"), ("dpr", "dpr"), ("duration", "du"),    ("effect", "e"), ("end_offset", "eo"), ("flags", "fl"), ("fetch_format", "f"), ("gravity", "g"), ("height", "h"), ("opacity", "o"), ("overlay", "l"),    ("page", "pg"), ("quality", "q"), ("radius", "r"), ("start_offset", "so"), ("underlay", "u"), ("video_codec", "vc"), ("width", "w"), ("x", "x"), ("y", "y"), ("zoom", "z"),];fn step(t: &Value) -> Result<String, String> {    let m: &Map<String, Value> = match t {        Value::String(s) => return Ok(s.clone()),        Value::Object(m) => m,        _ => return Err("a transformation is a map, like { width: 300, crop: \"fill\" }, or Cloudinary's text".into()),    };    let mut parts = vec![];    for (k, v) in m {        let code = SHORT.iter().find(|(long, short)| long == k || short == k).map(|x| x.1).ok_or(format!("{} is not a transformation parameter this plugin knows: write the transformation as text", k))?;        let text = match v {            Value::String(s) => s.clone(),            Value::Array(a) => a.iter().map(|x| x.as_str().map_or(x.to_string(), str::to_string)).collect::<Vec<_>>().join("."),            o => o.to_string(),        };        parts.push(format!("{}_{}", code, text));    }    // SDKs sort the parameters of a step, so that one transformation has one URL.    parts.sort();    Ok(parts.join(","))}/// A transformation as URL text: a map becomes `c_fill,h_200,w_300`, parameters sorted as the SDKs do, a list becomes steps joined by `/`, and a text is used as it is.pub fn cloudinary_transformation(transformation: serde_json::Value) -> Result<String, String> {    match &transformation {        Value::Null => Ok(String::new()),        Value::Array(a) => Ok(a.iter().map(step).collect::<Result<Vec<_>, _>>()?.join("/")),        t => step(t),    }}fn url_part(s: &str) -> String {    s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~/:,!@$*()'".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}/// The delivery URL of an asset. `options`: `transformation` (as for `cloudinary_transformation`), `format` ("webp"), `version`, `resource_type`, `type` ("upload", "private", "authenticated"), `sign` (true adds the `s--...--` signature, which an authenticated asset or a strict transformation needs). No request is made.pub fn cloudinary_url(public_id: &str, options: serde_json::Value) -> Result<String, String> {    let cloud = need("CLOUDINARY_CLOUD_NAME")?;    let host = env("CLOUDINARY_DELIVERY_URL").unwrap_or_else(|| "https://res.cloudinary.com".into());    let t = cloudinary_transformation(options.get("transformation").cloned().unwrap_or(Value::Null))?;    let file = match options["format"].as_str() {        Some(f) => format!("{}.{}", public_id, f),        None => public_id.to_string(),    };    let mut parts = vec![host.trim_end_matches('/').to_string(), cloud, resource_of(&options), options["type"].as_str().unwrap_or("upload").to_string()];    if options["sign"] == true {        // Signed: the transformation and the id, not the version.        let signed = if t.is_empty() { file.clone() } else { format!("{}/{}", t, file) };        let secret = need("CLOUDINARY_API_SECRET")?;        let d = Sha1::digest(format!("{}{}", signed, secret).as_bytes());        let b = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(d);        parts.push(format!("s--{}--", &b[..8]));    }    if !t.is_empty() {        parts.push(t);    }    if let Some(v) = param_text(options.get("version").unwrap_or(&Value::Null)) {        parts.push(format!("v{}", v));    }    parts.push(url_part(&file));    Ok(parts.join("/"))}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}/// Checks a webhook from Cloudinary: `body` the raw body, `timestamp` and `signature` the `X-Cld-Timestamp` and `X-Cld-Signature` headers. Refused when not signed with this secret, or signed more than `max_age` seconds ago (7200 is what Cloudinary suggests; 0 skips that). Gives the notification.pub fn cloudinary_webhook(body: &str, timestamp: &str, signature: &str, max_age: i64) -> Result<Value, String> {    let secret = need("CLOUDINARY_API_SECRET")?;    let t: i64 = timestamp.parse().map_err(|_| format!("{:?} is not a timestamp", timestamp))?;    if !same(&digest_hex(&format!("{}{}{}", body, timestamp, secret)), &signature.to_ascii_lowercase()) {        return Err("the webhook's signature does not match: a wrong secret, or the body was changed (use the raw body)".into());    }    if max_age > 0 && (now() - t).abs() > max_age {        return Err(format!("the webhook was signed {} s ago, more than the {} s allowed", (now() - t).abs(), max_age));    }    serde_json::from_str(body).map_err(|e| format!("the webhook's body is not JSON: {}", e))}/// Signs a webhook body as Cloudinary does, for tests: the `X-Cld-Signature` value.pub fn cloudinary_webhook_sign(body: &str, timestamp: &str) -> Result<String, String> {    Ok(digest_hex(&format!("{}{}{}", body, timestamp, need("CLOUDINARY_API_SECRET")?)))}/// Checks the `signature` of an upload's answer, for when a browser reports an upload it made itself: true when `public_id` and `version` are what Cloudinary signed.pub fn cloudinary_verify_upload(public_id: &str, version: &str, signature: &str) -> Result<bool, String> {    Ok(same(&digest_hex(&format!("public_id={}&version={}{}", public_id, version, need("CLOUDINARY_API_SECRET")?)), signature))}
Azure Blob SAS links azure_sas.qwer · 1.0.0Azure Blob Storage SAS links signed from the account key: browsers upload and download blobs directly, with the permissions and lifetime you choose.uploadsstorageazurefilessigning8 views · 7 downloadsGoogle Cloud Storage links gcs_sign.qwer · 1.0.0Google Cloud Storage V4 signed URLs from a service-account key: browsers upload and download directly, signed without a network call.uploadsstoragegcpfilessigning6 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.crmwebhookssigningbatch1 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.ticketsgraphqlwebhookssigning1 view · 7 downloadsTelegram bots 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.chatbotswebhookssigning4 views · 8 downloadsWhatsApp Cloud API whatsapp.qwer · 1.0.0Send 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.chatmessagingwebhookssigning2 views · 6 downloads
Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

On Cloudinary: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.