Payments

Square

Square's API without an SDK: card payments taken at once or held and completed later, refunds, customers and catalog items, lists followed through their cursors, an idempotency key on every write that a retry reuses, money in each currency's smallest unit, and webhook signatures checked over the notification URL and body.

Version 1.0.0 · 20 functions · 5 crates

#payments#pos#webhooks#catalog

Functions

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

square_new_key() → str

A new idempotency key, a UUID in form: send the same key to make a retry of your own safe.

square_money(amount: str, currency: str) → anyor an error

Money as Square wants it: square_money("19.99", "USD") is { amount: 1999, currency: "USD" }, in the currency's smallest unit (yen has none, dinars three). Takes text so no float rounding creeps in; an error for more decimals than the currency has.

square_pay(source_id: str, money: any, params: any) → anyor an error

Takes a payment from source_id (a nonce from the Web Payments SDK, or a card on file) for money (from square_money). params adds what Square documents: autocomplete (false only authorizes), location_id, customer_id, reference_id, note, idempotency_key (one is made if not given). Gives the payment; a decline is an error naming its code.

square_payment(id: str) → anyor an error

One payment by id.

square_payment_complete(id: str) → anyor an error

Completes a payment taken with autocomplete: false.

square_payment_cancel(id: str) → anyor an error

Cancels a payment taken with autocomplete: false, before it is completed.

square_payments(filter: any, max_items: int) → listor an error

Payments through every page: filter is Square's (begin_time, end_time, location_id, sort_order, limit); max_items caps them (0: all).

square_refund(payment_id: str, money: any, reason: str, idempotency_key: str) → anyor an error

Refunds money of a payment; reason shows on the receipt and idempotency_key may be "" to make one. Gives the refund, usually PENDING at first.

square_refund_get(id: str) → anyor an error

One refund by id, to see it leave PENDING.

square_customer_create(fields: any) → anyor an error

Makes a customer from Square's fields (given_name, family_name, email_address, phone_number, reference_id...), with idempotency_key when you want one kept.

square_customer(id: str) → anyor an error

One customer by id.

square_customer_update(id: str, fields: any) → anyor an error

Changes a customer's fields; give version to be refused when someone changed it first.

square_customers(max_items: int) → listor an error

Customers through every page; max_items caps them (0: all).

square_customer_by_email(email: str) → listor an error

Customers with exactly this email address.

square_catalog_upsert(object: any) → anyor an error

Puts any catalog object (object with a type and, for a new one, an id starting with #). Gives { object, id_mappings }, the ids Square gave in place of yours.

square_catalog_item(name: str, variations: list) → anyor an error

Makes an ITEM with its variations: variations is a list of { name, price, currency } (price as text; Square needs at least one).

square_catalog_list(types: str, max_items: int) → listor an error

Catalog objects of the types given ("ITEM,CATEGORY", "" for all) through every page; max_items caps them (0: all).

square_catalog_delete(id: str) → anyor an error

Deletes a catalog object, and its children. Gives the ids deleted.

square_webhook(notification_url: str, body: str, signature: str, signature_key: str) → anyor an error

Checks a webhook: notification_url exactly as subscribed, body the raw body, signature the x-square-hmacsha256-signature header, signature_key the subscription's. Gives the event, or an error saying why it is refused.

square_webhook_sign(notification_url: str, body: str, signature_key: str) → str

Signs a webhook body as Square does, for tests.

Get it

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

Or with curl:

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

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

main.qwe
use square.qweruse square_new_key, square_money from square      // Quillo 0.6: only these

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

[dependencies]
ureq = "2"hmac = "0.12"sha2 = "0.10"base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }

Example

square.qwe
// Taking money with Square: a card payment, one authorized and captured later, a refund, a// customer found by email, an item in the catalog, lists past one page, and a webhook checked.////   SQUARE_ACCESS_TOKEN=... SQUARE_API_URL=https://connect.squareupsandbox.com quillo run square.qwe//   then open /pay, /later, /refund, /customers, /catalog, /lists, /hook-check and /failures//// In the sandbox, `cnon:card-nonce-ok` is a good card and `cnon:card-nonce-declined` a declined// one. Point a webhook subscription at POST /square/webhook, and set SQUARE_WEBHOOK_KEY and// SQUARE_WEBHOOK_URL to its signature key and exact URL.use "square.qwer"route GET /pay {    let paid = square_pay("cnon:card-nonce-ok", square_money("19.99", "USD")?, { reference_id: "order-1001", note: "Blue mug" })?    let read = square_payment(paid.id)?    { state: read.status, amount: read.amount_money.amount, reference: read.reference_id }}// The same key sent twice is one payment; a changed amount with that key is refused.route GET /again {    let key = square_new_key()    let one = square_pay("cnon:card-nonce-ok", square_money("5.00", "USD")?, { idempotency_key: key })?    let two = square_pay("cnon:card-nonce-ok", square_money("5.00", "USD")?, { idempotency_key: key })?    {        same: one.id == two.id,        changed: square_pay("cnon:card-nonce-ok", square_money("6.00", "USD")?, { idempotency_key: key }).error,        long: square_pay("cnon:card-nonce-ok", square_money("6.00", "USD")?, { idempotency_key: repeat("k", 46) }).error,    }}route GET /later {    let held = square_pay("cnon:card-nonce-ok", square_money("40", "USD")?, { autocomplete: false })?    let done = square_payment_complete(held.id)?    let other = square_pay("cnon:card-nonce-ok", square_money("7", "USD")?, { autocomplete: false })?    { held: held.status, done: done.status, cancelled: square_payment_cancel(other.id)?.status, twice: square_payment_complete(other.id).error }}route GET /refund {    let paid = square_pay("cnon:card-nonce-ok", square_money("30", "USD")?, {})?    let back = square_refund(paid.id, square_money("10", "USD")?, "Chipped mug", "")?    {        state: back.status,        settled: square_refund_get(back.id)?.status,        too_much: square_refund(paid.id, square_money("99", "USD")?, "", "").error,    }}route GET /customers {    let ada = square_customer_create({ given_name: "Ada", family_name: "Lovelace", email_address: "ada@example.com" })?    square_customer_update(ada.id, { note: "Prefers mail", version: ada.version })?    {        name: square_customer(ada.id)?.given_name,        note: square_customer(ada.id)?.note,        found: map(square_customer_by_email("ada@example.com")?, (c) => c.family_name),        stale: square_customer_update(ada.id, { note: "x", version: 0 }).error,        bad: square_customer_create({ email_address: "not an address" }).error,    }}route GET /catalog {    let made = square_catalog_item("Blue mug", [{ name: "Small", price: "12.50", currency: "USD" }, { name: "Large", price: "1500", currency: "JPY" }])?    let item = made.object    {        name: item.item_data.name,        prices: map(item.item_data.variations, (v) => v.item_variation_data.price_money.amount),        temp_ids: map(made.id_mappings, (m) => m.client_object_id),        deleted: len(square_catalog_delete(item.id)?),        none: square_catalog_item("x", []).error,    }}route GET /lists = {    payments: len(square_payments({ location_id: "L1" }, 0)?),    capped: len(square_payments({ location_id: "L1" }, 130)?),    customers: len(square_customers(0)?),    items: len(square_catalog_list("ITEM", 0)?),    categories: len(square_catalog_list("CATEGORY", 0)?),}route GET /money = {    dollars: square_money("19.99", "usd")?,    yen: square_money("1500", "JPY")?,    dinars: square_money("1.250", "KWD")?,    too_fine: square_money("10.5", "JPY").error,    not_money: square_money("ten", "USD").error,    code: square_money("1", "US").error,}route POST /square/webhook {    let event = square_webhook(env("SQUARE_WEBHOOK_URL", ""), request.body, request.headers["x-square-hmacsha256-signature"] ?? "", env("SQUARE_WEBHOOK_KEY", ""))    if is_err(event) { reply 403 event.error }    { got: event.type }}// A signature made outside this plugin, by node's crypto.route GET /hook-check {    let url = "https://example.com/square/webhook"    let body = "{\"merchant_id\":\"6SSW7HV8K2ST5\",\"type\":\"payment.updated\",\"event_id\":\"e1\"}"    let sig = "wIOiyGj8OCbrrClH8Rvf4GlTu6SITiDf5SqDHCIAbYk="    {        good: square_webhook(url, body, sig, "whk-test-key")?.type,        tampered: square_webhook(url, body + " ", sig, "whk-test-key").error,        other_url: square_webhook("https://example.com/other", body, sig, "whk-test-key").error,        unsigned: square_webhook(url, body, "", "whk-test-key").error,    }}route GET /failures = {    declined: square_pay("cnon:card-nonce-declined", square_money("1", "USD")?, {}).error,    missing: square_payment("nope").error,    waited: square_pay("cnon:rate-limit-once", square_money("2", "USD")?, {})?.status,}
$ quillo run square.qwe$ curl 'localhost:8080/pay'$ curl 'localhost:8080/again'$ curl 'localhost:8080/later'$ curl 'localhost:8080/refund'$ curl 'localhost:8080/customers'$ curl 'localhost:8080/catalog'$ curl 'localhost:8080/lists'$ curl 'localhost:8080/money'$ curl 'localhost:8080/hook-check'$ curl 'localhost:8080/failures'

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

square.qwer
//! Square's API without an SDK: payments taken, completed and cancelled, refunds, customers and catalog items, every list followed through its cursors, an idempotency key on each write that a retry reuses, money in each currency's smallest unit, Square's error lists read, and webhook signatures checked over the notification URL and body.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! hmac = "0.12"//! sha2 = "0.10"//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::{engine::general_purpose::STANDARD, Engine};use hmac::{Hmac, Mac};use serde_json::{json, Value};use sha2::{Digest, Sha256};use std::sync::atomic::{AtomicU64, Ordering};use std::time::{Duration, SystemTime, UNIX_EPOCH};// SQUARE_ACCESS_TOKEN is the token; SQUARE_API_URL the API (https://connect.squareup.com when not// set; https://connect.squareupsandbox.com for the sandbox); SQUARE_VERSION an API version date.const ZERO_DECIMAL: &[&str] = &["BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"];const THREE_DECIMAL: &[&str] = &["BHD", "JOD", "KWD", "OMR", "TND"];const VERSION: &str = "2024-10-17";fn setting(k: &str) -> Option<String> {    std::env::var(k).ok().filter(|v| !v.is_empty())}fn api() -> String {    setting("SQUARE_API_URL").unwrap_or_else(|| "https://connect.squareup.com".into()).trim_end_matches('/').to_string()}fn enc(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()}// Square gives a list: each with a category, a code, and usually a detail and a field.fn errors_text(status: u16, v: &Value, raw: &str) -> String {    let list = v["errors"].as_array().cloned().unwrap_or_default();    if list.is_empty() {        return format!("Square {}: {}", status, raw.trim().chars().take(300).collect::<String>());    }    let parts: Vec<String> = list        .iter()        .map(|e| {            let mut t = format!("{}: {}", e["code"].as_str().unwrap_or("ERROR"), e["detail"].as_str().unwrap_or(e["category"].as_str().unwrap_or("")));            if let Some(f) = e["field"].as_str() {                t.push_str(&format!(" (field {})", f));            }            t        })        .collect();    let hint = match status {        401 => " (check SQUARE_ACCESS_TOKEN, and that it belongs to this environment)",        403 => " (the token lacks the permission this call needs)",        _ => "",    };    format!("Square {}: {}{}", status, parts.join("; "), hint)}// The body keeps its idempotency key through the retries, so a write sent twice happens once.fn call(method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> {    let token = setting("SQUARE_ACCESS_TOKEN").ok_or("SQUARE_ACCESS_TOKEN is not set")?;    let url = format!("{}{}", api(), path);    let version = setting("SQUARE_VERSION").unwrap_or_else(|| VERSION.into());    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build();    for attempt in 0..5u32 {        let req = agent.request(method, &url).set("Authorization", &format!("Bearer {}", token)).set("Square-Version", &version).set("Accept", "application/json");        let res = match body {            Some(b) => req.set("Content-Type", "application/json").send_string(&b.to_string()),            None => req.call(),        };        match res {            Ok(r) => {                let t = r.into_string().map_err(|e| e.to_string())?;                return if t.trim().is_empty() { Ok(json!({})) } else { serde_json::from_str(&t).map_err(|_| format!("Square's answer is not JSON: {}", t.chars().take(200).collect::<String>())) };            }            Err(ureq::Error::Status(429 | 500 | 502 | 503 | 504, r)) if attempt < 4 => {                let wait = r.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).map_or(250u64 << attempt, |s| s * 1000);                std::thread::sleep(Duration::from_millis(wait.min(30_000)));            }            Err(ureq::Error::Status(c, r)) => {                let t = r.into_string().unwrap_or_default();                return Err(errors_text(c, &serde_json::from_str(&t).unwrap_or(Value::Null), &t));            }            Err(e) => return Err(format!("cannot reach Square at {}: {}", api(), e)),        }    }    Err("Square stayed busy through five tries".into())}static COUNTER: AtomicU64 = AtomicU64::new(0);/// A new idempotency key, a UUID in form: send the same key to make a retry of your own safe.pub fn square_new_key() -> String {    let n = COUNTER.fetch_add(1, Ordering::Relaxed);    let t = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos());    let seed = std::collections::hash_map::RandomState::new();    let d = Sha256::digest(format!("{}:{}:{}:{:?}", t, n, std::process::id(), seed));    let mut b: Vec<u8> = d[..16].to_vec();    b[6] = (b[6] & 0x0f) | 0x40;    b[8] = (b[8] & 0x3f) | 0x80;    let h: String = b.iter().map(|x| format!("{:02x}", x)).collect();    format!("{}-{}-{}-{}-{}", &h[..8], &h[8..12], &h[12..16], &h[16..20], &h[20..])}fn with_key(mut v: Value, key_of: &str) -> Result<Value, String> {    let k = if key_of.is_empty() { v["idempotency_key"].as_str().map(str::to_string).unwrap_or_else(square_new_key) } else { key_of.to_string() };    if k.len() > 45 {        return Err(format!("an idempotency key is at most 45 characters, not {}", k.len()));    }    v["idempotency_key"] = json!(k);    Ok(v)}/// Money as Square wants it: `square_money("19.99", "USD")` is `{ amount: 1999, currency: "USD" }`, in the currency's smallest unit (yen has none, dinars three). Takes text so no float rounding creeps in; an error for more decimals than the currency has.pub fn square_money(amount: &str, currency: &str) -> Result<serde_json::Value, String> {    let c = currency.to_uppercase();    if c.len() != 3 || !c.bytes().all(|b| b.is_ascii_uppercase()) {        return Err(format!("`{}` is not a three-letter currency code", currency));    }    let places = if ZERO_DECIMAL.contains(&c.as_str()) { 0 } else if THREE_DECIMAL.contains(&c.as_str()) { 3 } else { 2 };    let a = amount.trim();    let (whole, frac) = a.split_once('.').unwrap_or((a, ""));    if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) || !frac.bytes().all(|b| b.is_ascii_digit()) {        return Err(format!("`{}` is not an amount", amount));    }    let frac = frac.trim_end_matches('0');    if frac.len() > places {        return Err(format!("{} has {} decimals; {} has {}", amount, frac.len(), c, places));    }    let n: i64 = format!("{}{}{}", whole, frac, "0".repeat(places - frac.len())).parse().map_err(|_| format!("`{}` is too large", amount))?;    Ok(json!({ "amount": n, "currency": c }))}/// Takes a payment from `source_id` (a nonce from the Web Payments SDK, or a card on file) for `money` (from square_money). `params` adds what Square documents: `autocomplete` (false only authorizes), `location_id`, `customer_id`, `reference_id`, `note`, `idempotency_key` (one is made if not given). Gives the payment; a decline is an error naming its code.pub fn square_pay(source_id: &str, money: serde_json::Value, params: serde_json::Value) -> Result<serde_json::Value, String> {    if source_id.is_empty() {        return Err("a payment needs a source_id".into());    }    if !money["amount"].is_i64() || !money["currency"].is_string() {        return Err("money must be { amount, currency }: see square_money".into());    }    let mut b = if params.is_object() { params } else { json!({}) };    b["source_id"] = json!(source_id);    b["amount_money"] = money;    let b = with_key(b, "")?;    Ok(call("POST", "/v2/payments", Some(&b))?["payment"].clone())}/// One payment by id.pub fn square_payment(id: &str) -> Result<serde_json::Value, String> {    Ok(call("GET", &format!("/v2/payments/{}", enc(id)), None)?["payment"].clone())}/// Completes a payment taken with `autocomplete: false`.pub fn square_payment_complete(id: &str) -> Result<serde_json::Value, String> {    Ok(call("POST", &format!("/v2/payments/{}/complete", enc(id)), Some(&json!({})))?["payment"].clone())}/// Cancels a payment taken with `autocomplete: false`, before it is completed.pub fn square_payment_cancel(id: &str) -> Result<serde_json::Value, String> {    Ok(call("POST", &format!("/v2/payments/{}/cancel", enc(id)), Some(&json!({})))?["payment"].clone())}fn query_of(filter: &Value) -> String {    let mut q = vec![];    if let Some(m) = filter.as_object() {        for (k, v) in m {            let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());            if !v.is_null() {                q.push(format!("{}={}", enc(k), enc(&t)));            }        }    }    q.join("&")}// GET lists: `cursor` in the query, `cursor` in the answer until the last page.fn walk_get(path: &str, filter: &Value, field: &str, max_items: i64) -> Result<Vec<Value>, String> {    let mut out = vec![];    let mut cursor: Option<String> = None;    let base_q = query_of(filter);    loop {        let mut q = base_q.clone();        if let Some(c) = &cursor {            if !q.is_empty() {                q.push('&');            }            q.push_str(&format!("cursor={}", enc(c)));        }        let url = if q.is_empty() { path.to_string() } else { format!("{}?{}", path, q) };        let v = call("GET", &url, None)?;        out.extend(v[field].as_array().cloned().unwrap_or_default());        if max_items > 0 && out.len() as i64 >= max_items {            out.truncate(max_items as usize);            return Ok(out);        }        match v["cursor"].as_str() {            Some(c) if !c.is_empty() => cursor = Some(c.to_string()),            _ => return Ok(out),        }    }}/// Payments through every page: `filter` is Square's (`begin_time`, `end_time`, `location_id`, `sort_order`, `limit`); `max_items` caps them (0: all).pub fn square_payments(filter: serde_json::Value, max_items: i64) -> Result<Vec<serde_json::Value>, String> {    walk_get("/v2/payments", &filter, "payments", max_items)}/// Refunds `money` of a payment; `reason` shows on the receipt and `idempotency_key` may be "" to make one. Gives the refund, usually PENDING at first.pub fn square_refund(payment_id: &str, money: serde_json::Value, reason: &str, idempotency_key: &str) -> Result<serde_json::Value, String> {    let mut b = json!({ "payment_id": payment_id, "amount_money": money });    if !reason.is_empty() {        b["reason"] = json!(reason);    }    let b = with_key(b, idempotency_key)?;    Ok(call("POST", "/v2/refunds", Some(&b))?["refund"].clone())}/// One refund by id, to see it leave PENDING.pub fn square_refund_get(id: &str) -> Result<serde_json::Value, String> {    Ok(call("GET", &format!("/v2/refunds/{}", enc(id)), None)?["refund"].clone())}/// Makes a customer from Square's fields (`given_name`, `family_name`, `email_address`, `phone_number`, `reference_id`...), with `idempotency_key` when you want one kept.pub fn square_customer_create(fields: serde_json::Value) -> Result<serde_json::Value, String> {    let b = with_key(if fields.is_object() { fields } else { json!({}) }, "")?;    Ok(call("POST", "/v2/customers", Some(&b))?["customer"].clone())}/// One customer by id.pub fn square_customer(id: &str) -> Result<serde_json::Value, String> {    Ok(call("GET", &format!("/v2/customers/{}", enc(id)), None)?["customer"].clone())}/// Changes a customer's fields; give `version` to be refused when someone changed it first.pub fn square_customer_update(id: &str, fields: serde_json::Value) -> Result<serde_json::Value, String> {    Ok(call("PUT", &format!("/v2/customers/{}", enc(id)), Some(&fields))?["customer"].clone())}/// Customers through every page; `max_items` caps them (0: all).pub fn square_customers(max_items: i64) -> Result<Vec<serde_json::Value>, String> {    walk_get("/v2/customers", &json!({ "limit": 100 }), "customers", max_items)}/// Customers with exactly this email address.pub fn square_customer_by_email(email: &str) -> Result<Vec<serde_json::Value>, String> {    let mut out = vec![];    let mut body = json!({ "limit": 100, "query": { "filter": { "email_address": { "exact": email } } } });    loop {        let v = call("POST", "/v2/customers/search", Some(&body))?;        out.extend(v["customers"].as_array().cloned().unwrap_or_default());        match v["cursor"].as_str() {            Some(c) if !c.is_empty() => body["cursor"] = json!(c),            _ => return Ok(out),        }    }}/// Puts any catalog object (`object` with a `type` and, for a new one, an `id` starting with `#`). Gives { object, id_mappings }, the ids Square gave in place of yours.pub fn square_catalog_upsert(object: serde_json::Value) -> Result<serde_json::Value, String> {    if !object["type"].is_string() || !object["id"].is_string() {        return Err("a catalog object needs a `type` and an `id` (a new one's starts with #)".into());    }    let b = with_key(json!({ "object": object }), "")?;    let v = call("POST", "/v2/catalog/object", Some(&b))?;    Ok(json!({ "object": v["catalog_object"], "id_mappings": v["id_mappings"] }))}/// Makes an ITEM with its variations: `variations` is a list of { name, price, currency } (price as text; Square needs at least one).pub fn square_catalog_item(name: &str, variations: Vec<serde_json::Value>) -> Result<serde_json::Value, String> {    if variations.is_empty() {        return Err("an item needs at least one variation".into());    }    let mut vars = vec![];    for (i, x) in variations.iter().enumerate() {        let vn = x["name"].as_str().ok_or("each variation needs a `name`")?;        let price = x["price"].as_str().ok_or("each variation needs a `price`, as text")?;        vars.push(json!({            "type": "ITEM_VARIATION", "id": format!("#var{}", i),            "item_variation_data": { "item_id": "#item", "name": vn, "pricing_type": "FIXED_PRICING", "price_money": square_money(price, x["currency"].as_str().unwrap_or("USD"))? },        }));    }    square_catalog_upsert(json!({ "type": "ITEM", "id": "#item", "item_data": { "name": name, "variations": vars } }))}/// Catalog objects of the `types` given ("ITEM,CATEGORY", "" for all) through every page; `max_items` caps them (0: all).pub fn square_catalog_list(types: &str, max_items: i64) -> Result<Vec<serde_json::Value>, String> {    let filter = if types.is_empty() { json!({}) } else { json!({ "types": types }) };    walk_get("/v2/catalog/list", &filter, "objects", max_items)}/// Deletes a catalog object, and its children. Gives the ids deleted.pub fn square_catalog_delete(id: &str) -> Result<serde_json::Value, String> {    Ok(call("DELETE", &format!("/v2/catalog/object/{}", enc(id)), None)?["deleted_object_ids"].clone())}fn sign(notification_url: &str, body: &str, signature_key: &str) -> String {    let mut m = Hmac::<Sha256>::new_from_slice(signature_key.as_bytes()).expect("HMAC takes any key");    m.update(notification_url.as_bytes());    m.update(body.as_bytes());    STANDARD.encode(m.finalize().into_bytes())}/// Checks a webhook: `notification_url` exactly as subscribed, `body` the raw body, `signature` the `x-square-hmacsha256-signature` header, `signature_key` the subscription's. Gives the event, or an error saying why it is refused.pub fn square_webhook(notification_url: &str, body: &str, signature: &str, signature_key: &str) -> Result<serde_json::Value, String> {    if signature.is_empty() {        return Err("no x-square-hmacsha256-signature header: this did not come from Square".into());    }    let want = sign(notification_url, body, signature_key);    if want.len() != signature.len() || want.bytes().zip(signature.bytes()).fold(0u8, |a, (x, y)| a | (x ^ y)) != 0 {        return Err("the webhook's signature does not match: a wrong signature key, a notification URL not exactly as subscribed, or a changed body".into());    }    serde_json::from_str(body).map_err(|e| format!("the webhook's body is not JSON: {}", e))}/// Signs a webhook body as Square does, for tests.pub fn square_webhook_sign(notification_url: &str, body: &str, signature_key: &str) -> String {    sign(notification_url, body, signature_key)}
Adyen adyen.qwer · 1.0.0Adyen's Checkout API without an SDK: sessions for Drop-in, payments with their result codes turned into paid, failed, pending or waiting on the shopper, 3-D Secure details sent on, captures, refunds, cancels and reversals, amounts by Adyen's own table of minor units, and each webhook notification's HMAC checked.paymentscheckoutwebhookshmac2 views · 7 downloadsMollie mollie.qwer · 1.0.0Mollie's Payments API without an SDK: payments and refunds with amounts in the exact decimals Mollie insists on, customers, mandates and recurring charges, lists followed through their next links, and webhooks taken as Mollie means them: only an id comes in, and the payment is fetched back to learn what happened.paymentsmandateswebhooksrecurring4 views · 8 downloadsPaddle Billing paddle.qwer · 1.0.0Paddle Billing's API without an SDK: prices, transactions and subscriptions, lists followed through their next links, subscription changes with the proration Paddle insists on, pauses, resumes and cancels, money as Paddle's strings of the lowest denomination, and the Paddle-Signature header checked with its timestamp and rotating keys.paymentssubscriptionsmerchant-of-recordwebhooks5 views · 7 downloadsPayPal paypal.qwer · 1.0.0PayPal's Orders API without an SDK: an OAuth token reused until it expires and renewed on a 401, orders created and captured, refunds, webhooks checked through PayPal's verify call on their exact bytes, and retries made safe with PayPal-Request-Id, each error given with PayPal's issue and debug id.paymentscheckoutwebhooksrefunds3 views · 6 downloadsRazorpay razorpay.qwer · 1.0.0Razorpay's API without an SDK: orders in paise, the signature Checkout hands the page checked against your own order, payments captured and refunded, lists walked by count and skip, webhook signatures, and rupees written with lakh and crore grouping.paymentsindiawebhooksupi3 views · 7 downloadsWebhooks webhooks.qwer · 1.0.0Check that a webhook came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender, with timestamp tolerance; and sign your own.webhooksapipaymentssigning5 views · 9 downloads
Thread
25Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.