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 thirteen 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();url names a fetch instruction rather than an output, and jpg is only an input spelling of
jpeg, so neither is a target here — 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, and Ipl and Sbpl were added later still.
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 worth knowing when calling the API by hand, both of which the SDK handles for you:
Acceptis flexible. The target’s exact media type, a wildcard subtype (image/*) and*/*all work, and acharsetorqparameter is ignored. The snippet above sends*/*simply because it is the one value that needs no per-target logic.- Options travel either way. A single
?params=<JSON>object and flat dot notation —?label.width=4&dpi=203,?data=[{"sku":"A-100"}]— both work, and where the two overlap the dot-notation value wins. Picking one and staying with it keeps the query readable.
- 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.