Wise
Wise's Platform API without an SDK: profiles, quotes with their usable payment option picked out, recipients, transfers made safe to retry with a UUID of your own, funding from a balance, and the one-time token Wise answers with on a protected call, signed by your RSA key and the call sent again.
Version 1.0.0 · 16 functions · 5 crates
Functions
What use wise.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use wise_sign, wise_call from wise (Quillo 0.6).
wise_sign(one_time_token: str) → stror an errorSigns a one-time token as Wise wants: SHA-256 with RSA by your private key, in base64. The calls here do it themselves; this is for a call of your own.
wise_call(method: str, path: str, body: any) → anyor an errorAny other call: method, path like "/v4/profiles/1/balances?types=STANDARD", body or null. A signature is added when Wise asks for one.
wise_profiles() → anyor an errorThe profiles this token can act for: each with id and type.
wise_profile_id(kind: str) → intor an errorThe id of the "personal" or "business" profile. Every other call needs one.
wise_quote(profile_id: int, source: str, target: str, amount: float, side: str, params: any) → anyor an errorA quote: side says what amount is, "source" (what leaves) or "target" (what arrives); params adds targetAccount, payOut, preferredPayIn. Gives Wise's quote with best, its first usable option (payIn, fee, sourceAmount, targetAmount).
wise_quote_for_recipient(profile_id: int, quote_id: str, recipient_id: int) → anyor an errorTies a quote to a recipient, which can change its price: do this before making the transfer.
wise_recipient_create(profile_id: int, currency: str, kind: str, holder: str, details: any) → anyor an errorMakes a recipient: kind is Wise's account type ("iban", "sort_code", "aba"...), details the fields it needs. A wrong field is an error naming its path.
wise_recipients(profile_id: int, currency: str) → anyor an errorThe profile's recipients in a currency ("" for all).
wise_recipient_delete(id: int) → boolor an errorDeletes a recipient.
wise_new_transaction_id() → strA new UUID for a transfer's customer_transaction_id. Keep it with your payout: sent again, it finds the first transfer instead of making another.
wise_transfer(recipient_id: int, quote_id: str, customer_transaction_id: str, details: any) → anyor an errorMakes a transfer from a quote, unfunded: customer_transaction_id is a UUID of yours (wise_new_transaction_id), details Wise's map (reference, transferPurpose, sourceOfFunds). Gives the transfer, incoming_payment_waiting until wise_fund.
wise_transfer_get(id: int) → anyor an errorOne transfer by id.
wise_fund(profile_id: int, transfer_id: int) → anyor an errorPays for a transfer from the profile's balance, signing when Wise asks. Gives { type, status }; a REJECTED funding is an error naming Wise's code.
wise_transfer_cancel(id: int) → anyor an errorCancels a transfer not yet sent.
wise_transfers(profile_id: int, filter: any, max_items: int) → listor an errorThe profile's transfers through every page: filter adds status, createdDateStart, createdDateEnd, sourceCurrency, targetCurrency; max_items caps them (0: all).
wise_balances(profile_id: int) → anyor an errorThe profile's balances.
Get it
$ quillo install wise # wise.qwer into this folder$ quillo install wise --example # and its example, wise.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/wise.qwer$ curl -O https://quillolang.org/plugins/files/wise.qweThen, in your program, all of it or only what you call:
use wise.qweruse wise_sign, wise_call from wise // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"jsonwebtoken = "9"sha2 = "0.10"base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }Example
// Paying someone abroad with Wise: the profile found, a quote, a recipient, a transfer, and its// funding, where Wise asks for a signature and gets one.//// WISE_API_TOKEN=... WISE_PRIVATE_KEY_FILE=private.pem WISE_API_URL=https://api.sandbox.transferwise.tech quillo run wise.qwe// then open /profiles, /quote, /recipient, /transfer, /signing, /lists and /failures//// The public half of the key goes in Wise's settings. In the sandbox strong customer authentication// is off until you turn it on with that key.use "wise.qwer"route GET /profiles { let id = wise_profile_id("business")? { business: id, personal: wise_profile_id("personal")?, kinds: map(wise_profiles()?, (p) => p.type), none: wise_profile_id("charity").error }}route GET /quote { let pid = wise_profile_id("business")? let sent = wise_quote(pid, "gbp", "eur", 1000.0, "source", {})? let due = wise_quote(pid, "GBP", "EUR", 500.0, "target", {})? { rate: sent.rate, pay_in: sent.best.payIn, fee: sent.best.fee.total, arrives: sent.best.targetAmount, leaves: due.best.sourceAmount, bad_side: wise_quote(pid, "GBP", "EUR", 1.0, "middle", {}).error, none: wise_quote(pid, "GBP", "EUR", 0.0, "source", {}).error, refused: wise_quote(pid, "GBP", "XXX", 1.0, "source", {}).error, }}route GET /recipient { let pid = wise_profile_id("business")? let r = wise_recipient_create(pid, "eur", "iban", "Ada Lovelace", { legalType: "PRIVATE", iban: "DE89370400440532013000" })? { id_given: r.id != null, listed: len(wise_recipients(pid, "EUR")?), other: len(wise_recipients(pid, "USD")?), short_name: wise_recipient_create(pid, "EUR", "iban", "Ada", {}).error, bad_iban: wise_recipient_create(pid, "EUR", "iban", "Ada Lovelace", { iban: "DE00" }).error, deleted: wise_recipient_delete(r.id)?, }}// The whole path: funding is the call Wise protects with a one-time token to sign.route GET /transfer { let pid = wise_profile_id("business")? let r = wise_recipient_create(pid, "EUR", "iban", "Grace Hopper", { legalType: "PRIVATE", iban: "DE89370400440532013000" })? let q = wise_quote(pid, "GBP", "EUR", 250.0, "source", {})? let tied = wise_quote_for_recipient(pid, q.id, r.id)? let key = wise_new_transaction_id() let t = wise_transfer(r.id, q.id, key, { reference: "Invoice 1001" })? let again = wise_transfer(r.id, q.id, key, { reference: "Invoice 1001" })? let funded = wise_fund(pid, t.id)? { tied: tied.targetAccount == r.id, waiting: t.status, same: t.id == again.id, funded: funded.status, now: wise_transfer_get(t.id)?.status, not_uuid: wise_transfer(r.id, q.id, "order-1", {}).error, short_balance: wise_fund(pid, 777).error, }}route GET /signing { let pid = wise_profile_id("business")? { balances: map(wise_balances(pid)?, (b) => b.currency), other_key: wise_fund(222, 778).error, signature: len(wise_sign("5932d5b5-ec13-452f-8688-308feade7834")?), }}route GET /lists { let pid = wise_profile_id("business")? { all: len(wise_transfers(pid, { status: "outgoing_payment_sent" }, 0)?), capped: len(wise_transfers(pid, {}, 120)?) }}route GET /failures = { missing: wise_transfer_get(1).error, cancelled: wise_transfer_cancel(55).error, any_call: wise_call("get", "/v1/nosuch", null).error,}$ quillo run wise.qwe$ curl 'localhost:8080/profiles'$ curl 'localhost:8080/quote'$ curl 'localhost:8080/recipient'$ curl 'localhost:8080/transfer'$ curl 'localhost:8080/signing'$ curl 'localhost:8080/lists'$ 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
//! Wise's Platform API without an SDK: profiles, quotes with their payment options, recipients, transfers made safe to retry with a UUID of your own, funding from a balance, and the one-time token Wise answers with on a protected call, signed by your private key and the call sent again.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! jsonwebtoken = "9"//! sha2 = "0.10"//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine};use serde_json::{json, Value};use sha2::{Digest, Sha256};use std::sync::atomic::{AtomicU64, Ordering};use std::time::{Duration, SystemTime, UNIX_EPOCH};// WISE_API_TOKEN is the token; WISE_API_URL the API (https://api.wise.com when not set;// https://api.sandbox.transferwise.tech for the sandbox). WISE_PRIVATE_KEY holds the RSA private key// in PEM, or WISE_PRIVATE_KEY_FILE its file: its public half is uploaded to Wise, and only calls// under strong customer authentication need it.fn setting(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn api() -> String { setting("WISE_API_URL").unwrap_or_else(|| "https://api.wise.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()}fn private_key() -> Result<String, String> { match (setting("WISE_PRIVATE_KEY"), setting("WISE_PRIVATE_KEY_FILE")) { (Some(k), _) => Ok(k), (None, Some(p)) => std::fs::read_to_string(&p).map_err(|e| format!("{}: {}", p, e)), _ => Err("Wise wants this call signed: set WISE_PRIVATE_KEY or WISE_PRIVATE_KEY_FILE to the key whose public half you uploaded".into()), }}/// Signs a one-time token as Wise wants: SHA-256 with RSA by your private key, in base64. The calls here do it themselves; this is for a call of your own.pub fn wise_sign(one_time_token: &str) -> Result<String, String> { let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key()?.as_bytes()).map_err(|e| format!("the private key: {}", e))?; let sig = jsonwebtoken::crypto::sign(one_time_token.as_bytes(), &key, jsonwebtoken::Algorithm::RS256).map_err(|e| e.to_string())?; let raw = URL_SAFE_NO_PAD.decode(sig).map_err(|e| e.to_string())?; Ok(STANDARD.encode(raw))}fn error_text(status: u16, text: &str) -> String { let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); if let Some(list) = v["errors"].as_array().filter(|l| !l.is_empty()) { let parts: Vec<String> = list .iter() .map(|e| { let mut t = format!("{}: {}", e["code"].as_str().unwrap_or("error"), e["message"].as_str().unwrap_or("")); if let Some(p) = e["path"].as_str() { t.push_str(&format!(" (at {})", p)); } t }) .collect(); return format!("Wise {}: {}", status, parts.join("; ")); } let what = v["error_description"].as_str().or(v["message"].as_str()).or(v["error"].as_str()).map(str::to_string).unwrap_or_else(|| text.trim().chars().take(300).collect()); format!("Wise {}: {}", status, what)}fn fetch(method: &str, path: &str, body: Option<&Value>, content_type: &str) -> Result<Value, String> { let token = setting("WISE_API_TOKEN").ok_or("WISE_API_TOKEN is not set")?; let url = format!("{}{}", api(), path); let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build(); let mut ott: Option<(String, String)> = None; let mut limited = 0u32; loop { let mut req = agent.request(method, &url).set("Authorization", &format!("Bearer {}", token)).set("Accept", "application/json"); if let Some((t, s)) = &ott { req = req.set("X-2FA-Approval", t).set("X-Signature", s); } let res = match body { Some(b) => req.set("Content-Type", content_type).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(Value::Null) } else { serde_json::from_str(&t).map_err(|_| format!("Wise's answer is not JSON: {}", t.chars().take(200).collect::<String>())) }; } // Strong customer authentication: a 403 with a token to sign, and the same call again. Err(ureq::Error::Status(403, r)) if r.header("x-2fa-approval-result").map_or(false, |h| h.eq_ignore_ascii_case("REJECTED")) && r.header("x-2fa-approval").is_some() => { if ott.is_some() { return Err("Wise refused the signature on this call: is the public key of WISE_PRIVATE_KEY uploaded to this profile?".into()); } let t = r.header("x-2fa-approval").unwrap_or("").to_string(); let s = wise_sign(&t)?; ott = Some((t, s)); } Err(ureq::Error::Status(429 | 502 | 503 | 504, r)) if method == "GET" && limited < 3 => { let wait = r.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).map_or(400u64 << limited, |s| s * 1000); limited += 1; std::thread::sleep(Duration::from_millis(wait.min(30_000))); } Err(ureq::Error::Status(c, r)) => return Err(error_text(c, &r.into_string().unwrap_or_default())), Err(e) => return Err(format!("cannot reach Wise at {}: {}", api(), e)), } }}fn call(method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> { fetch(method, path, body, "application/json")}/// Any other call: `method`, `path` like "/v4/profiles/1/balances?types=STANDARD", `body` or null. A signature is added when Wise asks for one.pub fn wise_call(method: &str, path: &str, body: serde_json::Value) -> Result<serde_json::Value, String> { call(&method.to_uppercase(), path, if body.is_null() { None } else { Some(&body) })}/// The profiles this token can act for: each with `id` and `type`.pub fn wise_profiles() -> Result<serde_json::Value, String> { call("GET", "/v2/profiles", None)}/// The id of the "personal" or "business" profile. Every other call needs one.pub fn wise_profile_id(kind: &str) -> Result<i64, String> { let all = wise_profiles()?; let found = all.as_array().into_iter().flatten().find(|p| p["type"].as_str().map_or(false, |t| t.eq_ignore_ascii_case(kind))); found.and_then(|p| p["id"].as_i64()).ok_or(format!("this token has no {} profile", kind))}/// A quote: `side` says what `amount` is, "source" (what leaves) or "target" (what arrives); `params` adds `targetAccount`, `payOut`, `preferredPayIn`. Gives Wise's quote with `best`, its first usable option (`payIn`, `fee`, `sourceAmount`, `targetAmount`).pub fn wise_quote(profile_id: i64, source: &str, target: &str, amount: f64, side: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { if amount <= 0.0 { return Err("a quote's amount must be more than 0".into()); } let mut b = if params.is_object() { params } else { json!({}) }; b["sourceCurrency"] = json!(source.to_uppercase()); b["targetCurrency"] = json!(target.to_uppercase()); match side { "source" => b["sourceAmount"] = json!(amount), "target" => b["targetAmount"] = json!(amount), o => return Err(format!("`{}` is not a side: \"source\" or \"target\"", o)), } let mut q = call("POST", &format!("/v3/profiles/{}/quotes", profile_id), Some(&b))?; let best = q["paymentOptions"].as_array().and_then(|l| l.iter().find(|o| o["disabled"] != json!(true)).cloned()).unwrap_or(Value::Null); q["best"] = best; Ok(q)}/// Ties a quote to a recipient, which can change its price: do this before making the transfer.pub fn wise_quote_for_recipient(profile_id: i64, quote_id: &str, recipient_id: i64) -> Result<serde_json::Value, String> { fetch("PATCH", &format!("/v3/profiles/{}/quotes/{}", profile_id, enc(quote_id)), Some(&json!({ "targetAccount": recipient_id })), "application/merge-patch+json")}/// Makes a recipient: `kind` is Wise's account type ("iban", "sort_code", "aba"...), `details` the fields it needs. A wrong field is an error naming its path.pub fn wise_recipient_create(profile_id: i64, currency: &str, kind: &str, holder: &str, details: serde_json::Value) -> Result<serde_json::Value, String> { if holder.trim().split_whitespace().count() < 2 { return Err("Wise wants the account holder's full name".into()); } call("POST", "/v1/accounts", Some(&json!({ "profile": profile_id, "currency": currency.to_uppercase(), "type": kind, "accountHolderName": holder, "details": details })))}/// The profile's recipients in a currency ("" for all).pub fn wise_recipients(profile_id: i64, currency: &str) -> Result<serde_json::Value, String> { let mut p = format!("/v1/accounts?profile={}", profile_id); if !currency.is_empty() { p.push_str(&format!("¤cy={}", enc(¤cy.to_uppercase()))); } call("GET", &p, None)}/// Deletes a recipient.pub fn wise_recipient_delete(id: i64) -> Result<bool, String> { call("DELETE", &format!("/v1/accounts/{}", id), None)?; Ok(true)}static COUNTER: AtomicU64 = AtomicU64::new(0);/// A new UUID for a transfer's `customer_transaction_id`. Keep it with your payout: sent again, it finds the first transfer instead of making another.pub fn wise_new_transaction_id() -> 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 d = Sha256::digest(format!("{}:{}:{}:{:?}", t, n, std::process::id(), std::collections::hash_map::RandomState::new())); 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..])}/// Makes a transfer from a quote, unfunded: `customer_transaction_id` is a UUID of yours (wise_new_transaction_id), `details` Wise's map (`reference`, `transferPurpose`, `sourceOfFunds`). Gives the transfer, `incoming_payment_waiting` until wise_fund.pub fn wise_transfer(recipient_id: i64, quote_id: &str, customer_transaction_id: &str, details: serde_json::Value) -> Result<serde_json::Value, String> { let u = customer_transaction_id.as_bytes(); let shape = u.len() == 36 && [8, 13, 18, 23].iter().all(|i| u[*i] == b'-') && u.iter().enumerate().all(|(i, c)| [8, 13, 18, 23].contains(&i) || c.is_ascii_hexdigit()); if !shape { return Err(format!("`{}` is not a UUID: see wise_new_transaction_id", customer_transaction_id)); } call("POST", "/v1/transfers", Some(&json!({ "targetAccount": recipient_id, "quoteUuid": quote_id, "customerTransactionId": customer_transaction_id, "details": if details.is_object() { details } else { json!({}) } })))}/// One transfer by id.pub fn wise_transfer_get(id: i64) -> Result<serde_json::Value, String> { call("GET", &format!("/v1/transfers/{}", id), None)}/// Pays for a transfer from the profile's balance, signing when Wise asks. Gives { type, status }; a `REJECTED` funding is an error naming Wise's code.pub fn wise_fund(profile_id: i64, transfer_id: i64) -> Result<serde_json::Value, String> { let r = call("POST", &format!("/v3/profiles/{}/transfers/{}/payments", profile_id, transfer_id), Some(&json!({ "type": "BALANCE" })))?; if r["status"] == "REJECTED" { return Err(format!("Wise did not fund transfer {}: {}", transfer_id, r["errorCode"].as_str().unwrap_or("no reason given"))); } Ok(r)}/// Cancels a transfer not yet sent.pub fn wise_transfer_cancel(id: i64) -> Result<serde_json::Value, String> { call("PUT", &format!("/v1/transfers/{}/cancel", id), None)}/// The profile's transfers through every page: `filter` adds `status`, `createdDateStart`, `createdDateEnd`, `sourceCurrency`, `targetCurrency`; `max_items` caps them (0: all).pub fn wise_transfers(profile_id: i64, filter: serde_json::Value, max_items: i64) -> Result<Vec<serde_json::Value>, String> { let mut q = format!("profile={}", profile_id); if let Some(m) = filter.as_object() { for (k, v) in m { q.push_str(&format!("&{}={}", enc(k), enc(&v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())))); } } let mut out: Vec<Value> = vec![]; loop { let page = call("GET", &format!("/v1/transfers?{}&limit=100&offset={}", q, out.len()), None)?; let items = page.as_array().cloned().unwrap_or_default(); let n = items.len(); out.extend(items); if max_items > 0 && out.len() as i64 >= max_items { out.truncate(max_items as usize); return Ok(out); } if n < 100 { return Ok(out); } }}/// The profile's balances.pub fn wise_balances(profile_id: i64) -> Result<serde_json::Value, String> { call("GET", &format!("/v4/profiles/{}/balances?types=STANDARD", profile_id), None)}Related
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.2 views · 7 downloadsEMV QR codes emv_qr.qwer · 1.0.0EMVCo merchant-presented QR codes: TLV read and written with nested templates and named fields, the CRC checked, and codes made for Thailand's PromptPay, Singapore's PayNow, Malaysia's DuitNow and any other scheme's merchant template.6 views · 10 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.4 views · 8 downloadsNACHA ACH files nacha.qwer · 1.0.0US ACH files: fixed-width batches built with entry hash, control totals, trace numbers and padding to a block of ten, and files read back with routing check digits and totals checked, each problem naming its line, returns and notifications of change included.5 views · 6 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.5 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.3 views · 6 downloadsThread 0 posts
On Wise: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.