Zendesk
Run a help desk through Zendesk's API: tickets opened, changed and solved, public replies and internal notes, search and its export past 1000 results, every list walked by cursor, validation errors named by field, 429s waited out, and webhook signatures checked.
Version 1.0.0 · 11 functions · 5 crates
Functions
What use zendesk.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use zendesk_request, zendesk_all from zendesk (Quillo 0.6).
zendesk_request(method: str, path: str, body: any, options: any) → anyor an errorCalls any endpoint: method, path like /api/v2/users/me.json, body or null. Options: subdomain (else ZENDESK_SUBDOMAIN), base_url (else ZENDESK_API_URL), email and token (else ZENDESK_EMAIL, ZENDESK_API_TOKEN), oauth_token (else ZENDESK_OAUTH_TOKEN).
zendesk_all(path: str, key: str, options: any) → listor an errorEvery item of a list with cursor paging, at 100 a page, following meta.has_more and links.next: path like /api/v2/tickets.json, key the list's name ("tickets"). Options add max_items.
zendesk_ticket_create(params: any, options: any) → anyor an errorOpens a ticket: params holds subject, comment (a text, or { body, html_body, public }), requester ({ name, email }), priority, type, status, tags, assignee_id, group_id, custom_fields, external_id. Gives the ticket.
zendesk_ticket(id: int, options: any) → anyor an errorOne ticket by id.
zendesk_ticket_update(id: int, params: any, options: any) → anyor an errorChanges a ticket: status (new, open, pending, hold, solved, closed), priority, tags (replaces them), additional_tags, assignee_id, custom_fields, safe_update with updated_stamp. Gives the ticket.
zendesk_comment(id: int, body: str, public: bool, options: any) → anyor an errorAdds a comment: public false makes it an internal note the customer does not see. There is no endpoint for this: a comment goes on with a ticket update.
zendesk_comments(id: int, options: any) → listor an errorA ticket's comments, oldest first, all pages.
zendesk_tickets(options: any) → listor an errorAll tickets, by cursor. Options: max_items.
zendesk_search(query: str, options: any) → anyor an errorSearches with Zendesk's query language (type:ticket status:open). Gives { count, results } of the first page of up to 100; it can reach 1000 results, so use zendesk_search_export for more.
zendesk_search_export(query: str, kind: str, options: any) → listor an errorEverything a search finds, past the 1000 limit, through the export endpoint: kind is ticket, user, organization or group. Cursors last an hour. Options add max_items.
zendesk_webhook_ok(body: str, signature: str, timestamp: str, secret: str) → boolor an errorWhether a webhook is really from this Zendesk: the raw body, its X-Zendesk-Webhook-Signature and X-Zendesk-Webhook-Signature-Timestamp headers, and the webhook's signing secret ("" for ZENDESK_WEBHOOK_SECRET). The signature is a base64 HMAC-SHA256 of the timestamp and body.
Get it
$ quillo install zendesk # zendesk.qwer into this folder$ quillo install zendesk --example # and its example, zendesk.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/zendesk.qwer$ curl -O https://quillolang.org/plugins/files/zendesk.qweThen, in your program, all of it or only what you call:
use zendesk.qweruse zendesk_request, zendesk_all from zendesk // 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"base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }Example
// A help desk through Zendesk: a ticket opened from a form, a public reply and an internal note,// all open tickets read past a page, a search, and a webhook checked before it is believed.//// ZENDESK_SUBDOMAIN=acme ZENDESK_EMAIL=agent@acme.com ZENDESK_API_TOKEN=... ZENDESK_WEBHOOK_SECRET=... quillo run zendesk.qwe// then open http://localhost:8080/ticket, /list, /search, /hook-check and /failures//// ZENDESK_API_URL reaches a test server.use "zendesk.qwer"route GET /ticket { let t = zendesk_ticket_create({ subject: "Blue mug arrived broken", comment: "It came in two pieces.", priority: "high", requester: { name: "Ada", email: "ada@example.com" }, tags: ["damage"] }, {})? zendesk_comment(t.id, "Sorry about that: a new one is on its way.", true, {})? zendesk_comment(t.id, "Refund approved by Sam.", false, {})? let solved = zendesk_ticket_update(t.id, { status: "solved", additional_tags: ["refunded"] }, {})? let notes = zendesk_comments(t.id, {})? { status: solved.status, tags: solved.tags, comments: map(notes, (c) => c.body), public: map(notes, (c) => c.public), read: zendesk_ticket(t.id, {})?.subject, }}route GET /list { let all = zendesk_tickets({})? { total: len(all), first: all[0].subject, capped: len(zendesk_tickets({ max_items: 120 })?) }}route GET /search { let r = zendesk_search("type:ticket status:open", {})? let every = zendesk_search_export("status:open", "ticket", {})? { count: r.count, page: len(r.results), every: len(every) }}let BODY = "{\"ticket\":1}"let STAMP = "2026-11-01T10:00:00Z"// Worked out outside the plugin, with the secret "zd-secret".let SIG = "bnJ/3N7EE0PMTAXdg5iEVc4ekzM32GtUXkogFAkrgBw="route GET /hook-check = { good: zendesk_webhook_ok(BODY, SIG, STAMP, "zd-secret")?, tampered: zendesk_webhook_ok(BODY + " ", SIG, STAMP, "zd-secret")?, other_time: zendesk_webhook_ok(BODY, SIG, "2026-11-01T10:00:01Z", "zd-secret")?, wrong_secret: zendesk_webhook_ok(BODY, SIG, STAMP, "other")?, not_base64: zendesk_webhook_ok(BODY, "!!!", STAMP, "zd-secret")?,}route POST /zendesk/hook { let h = request.headers let ok = zendesk_webhook_ok(request.body, h["x-zendesk-webhook-signature"] ?? "", h["x-zendesk-webhook-signature-timestamp"] ?? "", "")? if !ok { reply 401 "bad signature" } { got: true }}route GET /failures = { invalid: zendesk_ticket_create({ subject: "", comment: "x", priority: "soon" }, {}).error, missing: zendesk_ticket(999999, {}).error, bad_token: zendesk_ticket(1, { token: "wrong" }).error, no_comment: zendesk_ticket_create({ subject: "x" }, {}).error, empty: zendesk_comment(1, " ", true, {}).error, waited: zendesk_request("GET", "/api/v2/users/me.json", null, {})?.user.email,}$ quillo run zendesk.qwe$ curl 'localhost:8080/ticket'$ curl 'localhost:8080/list'$ curl 'localhost:8080/search'$ 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
//! Zendesk Support's API: tickets made, changed and commented on, search, lists walked with cursor paging, validation errors read by field, 429s waited out, and webhook signatures checked.//! 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;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()))}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 base(o: &Value) -> Result<String, String> { if let Some(u) = opt(o, "base_url", "ZENDESK_API_URL") { return Ok(u.trim_end_matches('/').to_string()); } let sub = opt(o, "subdomain", "ZENDESK_SUBDOMAIN").ok_or("no account: set ZENDESK_SUBDOMAIN (the first part of its address), or give the option subdomain")?; if !sub.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { return Err(format!("{:?} is not a subdomain", sub)); } Ok(format!("https://{}.zendesk.com", sub))}fn auth(o: &Value) -> Result<String, String> { if let Some(t) = opt(o, "oauth_token", "ZENDESK_OAUTH_TOKEN") { return Ok(format!("Bearer {}", t)); } let email = opt(o, "email", "ZENDESK_EMAIL").ok_or("no user: set ZENDESK_EMAIL, or give the option email")?; let token = opt(o, "token", "ZENDESK_API_TOKEN").ok_or("no token: set ZENDESK_API_TOKEN, or give the option token")?; Ok(format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("{}/token:{}", email, token))))}fn error_text(code: u16, body: &str) -> String { let v: Value = serde_json::from_str(body).unwrap_or(Value::Null); let kind = v["error"].as_str().map(str::to_string).or_else(|| v.pointer("/error/title").and_then(|e| e.as_str()).map(str::to_string)).unwrap_or_else(|| "Error".into()); let desc = v["description"].as_str().or(v.pointer("/error/message").and_then(|e| e.as_str())).map(|d| format!(": {}", d)).unwrap_or_default(); // A validation failure names each field in `details`. let details: Vec<String> = v["details"].as_object().map(|m| m.iter().flat_map(|(k, l)| l.as_array().into_iter().flatten().map(move |e| format!("{}: {}", k, e["description"].as_str().unwrap_or("invalid")))).collect()).unwrap_or_default(); let more = if details.is_empty() { String::new() } else { format!(" ({})", details.join("; ")) }; let hint = if code == 401 { " (check ZENDESK_EMAIL and ZENDESK_API_TOKEN)" } else { "" }; format!("Zendesk {} {}{}{}{}", code, kind, desc, more, hint)}fn call(method: &str, target: &str, body: Option<&Value>, o: &Value) -> Result<Value, String> { let b = base(o)?; let url = if target.starts_with("http") { target.to_string() } else { format!("{}{}", b, target) }; if !url.starts_with(&b) { return Err(format!("{} is not on {}", url, b)); } let header = auth(o)?; let agent = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(10)).timeout_read(Duration::from_secs(60)).build(); let text = body.map(|v| v.to_string()); let mut tries = 0; loop { let r = agent.request(method, &url).set("Authorization", &header).set("User-Agent", "quillo-zendesk/1.0"); let res = match &text { Some(t) => r.set("Content-Type", "application/json").send_string(t), None => r.call(), }; match res { Ok(resp) => { let t = resp.into_string().map_err(|e| e.to_string())?; if t.trim().is_empty() { return Ok(Value::Null); } return serde_json::from_str(&t).map_err(|_| format!("Zendesk's answer is not JSON: {:?}", t.chars().take(200).collect::<String>())); } Err(ureq::Error::Status(code, resp)) => { let wait = resp.header("retry-after").and_then(|h| h.trim().parse::<u64>().ok()); let t = resp.into_string().unwrap_or_default(); // 409 is two updates of one ticket meeting: it says to try again. if matches!(code, 409 | 429 | 503) && tries < 3 && wait.unwrap_or(1) <= 60 { std::thread::sleep(Duration::from_secs(wait.unwrap_or(1))); tries += 1; continue; } return Err(error_text(code, &t)); } Err(e) => return Err(format!("cannot reach Zendesk at {}: {}", b, e)), } }}/// Calls any endpoint: `method`, `path` like /api/v2/users/me.json, `body` or null. Options: subdomain (else ZENDESK_SUBDOMAIN), base_url (else ZENDESK_API_URL), email and token (else ZENDESK_EMAIL, ZENDESK_API_TOKEN), oauth_token (else ZENDESK_OAUTH_TOKEN).pub fn zendesk_request(method: &str, path: &str, body: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { call(&method.to_uppercase(), path, if body.is_null() { None } else { Some(&body) }, &options)}/// Every item of a list with cursor paging, at 100 a page, following meta.has_more and links.next: `path` like /api/v2/tickets.json, `key` the list's name ("tickets"). Options add max_items.pub fn zendesk_all(path: &str, key: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { let max = options.get("max_items").and_then(|v| v.as_u64()).unwrap_or(u64::MAX) as usize; let mut target = format!("{}{}page%5Bsize%5D=100", path, if path.contains('?') { "&" } else { "?" }); let mut out = vec![]; loop { let r = call("GET", &target, None, &options)?; out.extend(r[key].as_array().cloned().unwrap_or_default()); match (r.pointer("/meta/has_more"), r.pointer("/links/next").and_then(|n| n.as_str())) { (Some(Value::Bool(true)), Some(n)) if out.len() < max => target = n.to_string(), _ => break, } } out.truncate(max); Ok(out)}/// Opens a ticket: `params` holds subject, comment (a text, or { body, html_body, public }), requester ({ name, email }), priority, type, status, tags, assignee_id, group_id, custom_fields, external_id. Gives the ticket.pub fn zendesk_ticket_create(params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { let mut t = params; match t.get("comment") { Some(Value::String(s)) => { let c = json!({ "body": s }); t["comment"] = c; } Some(_) => {} None => return Err("a ticket needs a `comment`: its first message".into()), } Ok(call("POST", "/api/v2/tickets.json", Some(&json!({ "ticket": t })), &options)?["ticket"].clone())}/// One ticket by id.pub fn zendesk_ticket(id: i64, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call("GET", &format!("/api/v2/tickets/{}.json", id), None, &options)?["ticket"].clone())}/// Changes a ticket: status (new, open, pending, hold, solved, closed), priority, tags (replaces them), additional_tags, assignee_id, custom_fields, safe_update with updated_stamp. Gives the ticket.pub fn zendesk_ticket_update(id: i64, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call("PUT", &format!("/api/v2/tickets/{}.json", id), Some(&json!({ "ticket": params })), &options)?["ticket"].clone())}/// Adds a comment: `public` false makes it an internal note the customer does not see. There is no endpoint for this: a comment goes on with a ticket update.pub fn zendesk_comment(id: i64, body: &str, public: bool, options: serde_json::Value) -> Result<serde_json::Value, String> { if body.trim().is_empty() { return Err("a comment needs a body".into()); } zendesk_ticket_update(id, json!({ "comment": { "body": body, "public": public } }), options)}/// A ticket's comments, oldest first, all pages.pub fn zendesk_comments(id: i64, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { zendesk_all(&format!("/api/v2/tickets/{}/comments.json", id), "comments", options)}/// All tickets, by cursor. Options: max_items.pub fn zendesk_tickets(options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { zendesk_all("/api/v2/tickets.json", "tickets", options)}/// Searches with Zendesk's query language (`type:ticket status:open`). Gives { count, results } of the first page of up to 100; it can reach 1000 results, so use zendesk_search_export for more.pub fn zendesk_search(query: &str, options: serde_json::Value) -> Result<serde_json::Value, String> { let r = call("GET", &format!("/api/v2/search.json?query={}&per_page=100", enc(query)), None, &options)?; Ok(json!({ "count": r["count"], "results": r["results"], "next_page": r.get("next_page").cloned().unwrap_or(Value::Null) }))}/// Everything a search finds, past the 1000 limit, through the export endpoint: `kind` is ticket, user, organization or group. Cursors last an hour. Options add max_items.pub fn zendesk_search_export(query: &str, kind: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { zendesk_all(&format!("/api/v2/search/export.json?query={}&filter%5Btype%5D={}", enc(query), enc(kind)), "results", options)}/// Whether a webhook is really from this Zendesk: the raw `body`, its X-Zendesk-Webhook-Signature and X-Zendesk-Webhook-Signature-Timestamp headers, and the webhook's signing secret ("" for ZENDESK_WEBHOOK_SECRET). The signature is a base64 HMAC-SHA256 of the timestamp and body.pub fn zendesk_webhook_ok(body: &str, signature: &str, timestamp: &str, secret: &str) -> Result<bool, String> { let key = if secret.is_empty() { std::env::var("ZENDESK_WEBHOOK_SECRET").unwrap_or_default() } else { secret.to_string() }; if key.is_empty() { return Err("no signing secret: set ZENDESK_WEBHOOK_SECRET".into()); } let Ok(got) = base64::engine::general_purpose::STANDARD.decode(signature.trim()) else { return Ok(false) }; let mut m = Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("HMAC takes any key"); m.update(timestamp.as_bytes()); m.update(body.as_bytes()); Ok(m.verify_slice(&got).is_ok())}Related
asana.qwer · 1.0.0Asana's API without an SDK: tasks, projects and comments in Asana's data envelope, offset pages walked with an expired one named, opt_fields, Retry-After waited out, and webhooks from their handshake on: X-Hook-Secret answered and kept, refused once one is held, and each delivery's X-Hook-Signature checked.3 views · 6 downloadsCalendly calendly.qwer · 1.0.0Calendly's API v2 without an SDK: event types, scheduled events and their invitees through page tokens, cancels, webhook subscriptions with a signing key insisted on, error details read, resource addresses shortened to uuids, and the Calendly-Webhook-Signature checked with its timestamp tolerance.3 views · 9 downloadsGitHub API github_api.qwer · 1.0.0Work with GitHub from a program: issues, comments, pull requests and check runs, every list followed through its Link header, primary and secondary rate limits waited out or named, webhook signatures checked, and a GitHub App's RS256 JWT and installation tokens made and kept.2 views · 8 downloadsGitLab API gitlab_api.qwer · 1.0.0Work with GitLab from a program: issues, merge requests, notes and pipelines, projects named by their path, lists followed by X-Next-Page or keyset links, errors read whether they are texts or maps of fields, 429s waited out, and the webhook's token compared in constant time.4 views · 7 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 · 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 · 9 downloadsThread 0 posts
On Zendesk: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.