Skip to content

Rust Quickstart

Terminal window
cargo add labelzoom
labelzoom = "1.0"

Rust 1.85+. Blocking, with a rustls-backed HTTP stack — no async runtime to adopt, and no OpenSSL headers needed to build it anywhere.

An API key is optional — without one you get the free tier.

use labelzoom::{ConversionOptions, ConvertRequest, LabelZoomClient, SourceFormat, TargetFormat};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = LabelZoomClient::new(); // reads LABELZOOM_API_KEY; anonymous if unset
let result = client.convert(
&ConvertRequest::new(
SourceFormat::Zpl,
TargetFormat::Pdf,
"^XA^FO50,50^ADN,36,20^FDLabelZoom^FS^XZ",
)
.options(ConversionOptions::new().label_size(4.0, 6.0).dpi(203)),
)?;
result.save("label.pdf")?;
Ok(())
}

result.bytes is authoritative — five of the eleven targets are binary. result.text() decodes it with the response charset for the textual ones (zpl, xml, json), borrowing rather than copying when the bytes are already valid UTF-8.

Option<T> is the “only what you set” rule

Section titled “Option<T> is the “only what you set” rule”

Every field of ConversionOptions is an Option, and #[serde(skip_serializing_if)] is what makes only the ones you set reach the wire. The SDK never substitutes a default of its own, so a change to a server default reaches you without a crate upgrade — and setting nothing at all produces a bare URL with no query string:

ConversionOptions::new()
.watermark(false) // an explicit false IS sent
.pdf_page(0) // 0-based; omit for every page
.rotation(90) // must be a multiple of 90
let explicit = LabelZoomClient::builder().api_key("lz_live_...").build()?;
let from_env = LabelZoomClient::new(); // LABELZOOM_API_KEY
let anonymous = LabelZoomClient::builder().anonymous().build()?; // ignores the env

.api_key("") and .anonymous() both force the free tier and suppress the environment fallback; not calling either consults it. The client is Send + Sync — build one per application, not one per request.

let pdf = std::fs::read("shipping-label.pdf")?;
let result = client.convert(
&ConvertRequest::new(SourceFormat::Pdf, TargetFormat::Zpl, pdf)
.options(ConversionOptions::new().label_size(4.0, 6.0).pdf_page(0)),
)?;
let zpl = result.text();

jpg and url are source-only on the server, and the type system says so: SourceFormat and TargetFormat are separate enums, so TargetFormat::Url does not exist and passing a SourceFormat where a target belongs is a type error, not a runtime 404.

Both enums are #[non_exhaustive], and that is not boilerplate: Epl, Tspl and Dpl became targets after the printer-language writers shipped. Without it, every such addition would be a breaking change for any downstream match.

The printer languages are targets — PdfEpl is a real conversion. Read result.bytes rather than result.text() for those: EPL’s GW and TSPL’s BITMAP commands inline raw binary.

Each record produces one label:

use serde_json::json;
let options = ConversionOptions::new().with_data([
json!({ "name": "ACME Corp", "sku": "12345" }).as_object().unwrap().clone(),
json!({ "name": "Globex", "sku": "67890" }).as_object().unwrap().clone(),
]);

The free tier returns the first label only, so this is a 1-page PDF until a key is configured.

Rust has no inheritance, so “every API error shares one base type” is a single enum variant:

match client.convert(&request) {
Ok(result) => { /* … */ }
Err(labelzoom::Error::Api(e)) if e.is_paid_feature() => {
// "JSON export is a paid feature" — the most common free-tier refusal.
}
Err(labelzoom::Error::Api(e)) => {
eprintln!("request {:?} failed with {}: {}", e.request_id, e.status, e.message);
}
Err(labelzoom::Error::Validation(e)) => eprintln!("bad {}: {}", e.parameter, e.message),
Err(labelzoom::Error::Transport(e)) => eprintln!("no response: {e}"),
Err(other) => eprintln!("{other}"),
}

That last arm is required, not defensive: Error is #[non_exhaustive], so the compiler makes you handle a variant added in a future minor release rather than silently changing behaviour when one appears.

ApiError carries the status, the message, the untruncated raw body and the X-LZ-Request-Id support handle; ApiErrorKind says which class it is, with the per-status data on the variant that has it — Forbidden { is_paid_feature }, RateLimited { retry_after_seconds }.

Error::Validation is a sibling of Error::Api, not a member: it reports a request rejected locally, before any network call, which is a bug in the calling code rather than a server response.

429, 5xx and transport failures are retried automatically — 3 attempts, 1s/2s/4s with jitter, honouring a longer Retry-After. Other 4xx responses return immediately.

Testing your own code, and bringing your own HTTP stack

Section titled “Testing your own code, and bringing your own HTTP stack”

The HTTP backend is a trait, not a hardcoded client:

use labelzoom::{HttpRequest, HttpResponse, Transport, TransportError};
struct Stub;
impl Transport for Stub {
fn execute(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
Ok(HttpResponse {
status: 200,
headers: vec![("content-type".into(), "text/plain".into())],
body: b"^XA^XZ".to_vec(),
})
}
}
let client = LabelZoomClient::builder()
.transport(std::sync::Arc::new(Stub))
.jitter(false)
.env_lookup(|_| None)
.build()?;

That same trait is how you opt out of the bundled stack entirely:

labelzoom = { version = "1.0", default-features = false }

which drops ureq, rustls and ring and leaves you to supply a Transport. It is a supported configuration, built on every CI run rather than merely documented.

The SDK is a wrapper over one HTTP endpoint; any HTTP client works.

let zpl = "^XA^FO50,50^ADN,36,20^FDLabelZoom^FS^XZ";
let params = r#"{"label":{"width":4,"height":6},"dpi":203}"#;
let mut request = ureq::post("https://api.labelzoom.com/api/v2/convert/zpl/to/pdf")
.query("params", params)
.header("Content-Type", "text/plain")
.header("Accept", "*/*");
if let Ok(key) = std::env::var("LABELZOOM_API_KEY") {
request = request.header("Authorization", &format!("Bearer {key}"));
}
let mut response = request.send(zpl)?;
if response.status() != 200 {
return Err(format!("LabelZoom returned {}", response.status()).into());
}
std::fs::write("label.pdf", response.body_mut().read_to_vec()?)?;

Two things to watch when calling the API by hand, both of which the SDK handles for you:

  • Accept: */* is the safe default. Naming the target’s exact media type works, but */* is the one value valid for every target against every deployed server version — older servers answer an exact image/gif, image/bmp or image/jpeg with a 406.
  • Prefer a single params object. Dot notation carries JSON as well — ?label.width=4&dpi=203 and ?data=[{"sku":"A-100"}] both work — but one serialization path with no per-field exceptions is the habit that keeps working as you add options.