Rust Quickstart
Install
Section titled “Install”cargo add labelzoomlabelzoom = "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.
Convert ZPL to PDF
Section titled “Convert ZPL to PDF”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 90Configuring the client
Section titled “Configuring the client”let explicit = LabelZoomClient::builder().api_key("lz_live_...").build()?;let from_env = LabelZoomClient::new(); // LABELZOOM_API_KEYlet 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.
Converting a PDF to ZPL
Section titled “Converting a PDF to ZPL”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 — Pdf → Epl is a real conversion. Read result.bytes
rather than result.text() for those: EPL’s GW and TSPL’s BITMAP commands inline raw binary.
Filling variable fields
Section titled “Filling variable fields”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.
Handling errors
Section titled “Handling errors”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.
Calling the REST API directly
Section titled “Calling the REST API directly”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 exactimage/gif,image/bmporimage/jpegwith a406.- Prefer a single
paramsobject. Dot notation carries JSON as well —?label.width=4&dpi=203and?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.
- Reuse one client across conversions for connection pooling; see rate limits for per-plan throughput.
- All endpoint patterns and parameters: quickstart · Swagger reference.
- API documentation: docs.rs/labelzoom.
- Source, issues, and the shared conformance suite: labelzoom/labelzoom-sdk.