Skip to content

Go Quickstart

Terminal window
go get github.com/labelzoom/labelzoom-sdk/go
import labelzoom "github.com/labelzoom/labelzoom-sdk/go"

Go 1.23+, zero dependencies — standard library only, including the response-charset decoding.

The import path ends in /go because the module lives in the go/ directory of a multi-language repository. Its release tags are go/vX.Y.Z for the same reason.

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

package main
import (
"context"
"log"
labelzoom "github.com/labelzoom/labelzoom-sdk/go"
)
func main() {
client, err := labelzoom.New() // reads LABELZOOM_API_KEY; anonymous if unset
if err != nil {
log.Fatal(err)
}
result, err := client.Convert(context.Background(), labelzoom.ConvertRequest{
From: labelzoom.SourceZPL,
To: labelzoom.TargetPDF,
Body: []byte("^XA^FO50,50^ADN,36,20^FDLabelZoom^FS^XZ"),
Options: &labelzoom.Options{
Label: &labelzoom.LabelSize{Width: labelzoom.Ptr(4.0), Height: labelzoom.Ptr(6.0)},
DPI: labelzoom.Ptr(203),
},
})
if err != nil {
log.Fatal(err)
}
if err := result.Save("label.pdf"); err != nil {
log.Fatal(err)
}
}

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).

The other LabelZoom SDKs expose client.convert().fromZpl(...).toPdf().execute(). A fluent chain in Go has to either panic on a bad argument or hoard errors until a terminal Do(), and both are un-Go — so this SDK uses functional options for the client and a request struct for the call. The wire behaviour is identical; only the ergonomics differ.

Every field of Options is a pointer, and only the ones you set are sent. The SDK never substitutes a default of its own, so a change to a server default reaches you without an SDK upgrade. labelzoom.Ptr is the constructor:

Options: &labelzoom.Options{
Watermark: labelzoom.Ptr(false), // an explicit false IS sent
PDF: &labelzoom.PDFOptions{PageNumber: labelzoom.Ptr(0)},
}

Type inference follows the literal, so a float64 field wants Ptr(4.0), not Ptr(4).

explicit, _ := labelzoom.New(labelzoom.WithAPIKey("lz_live_..."))
fromEnv, _ := labelzoom.New() // reads LABELZOOM_API_KEY
anonymous, _ := labelzoom.New(labelzoom.WithAnonymous()) // ignores the env

Not calling WithAPIKey consults the environment; WithAPIKey("") — or the more readable WithAnonymous() — suppresses that fallback. The client is safe for concurrent use; create one per application, not one per request.

pdf, err := os.ReadFile("shipping-label.pdf")
if err != nil {
return err
}
result, err := client.Convert(ctx, labelzoom.ConvertRequest{
From: labelzoom.SourcePDF,
To: labelzoom.TargetZPL,
Body: pdf,
Options: &labelzoom.Options{PDF: &labelzoom.PDFOptions{PageNumber: labelzoom.Ptr(0)}},
})
zpl := result.Text()

jpg and url are source-only on the server, and the type system says so: SourceFormat and TargetFormat are distinct types with typed constants, so To: labelzoom.SourceURL does not compile and there is no TargetURL to write.

The printer languages epl, tspl and dpl are targets — SourcePDFTargetEPL is a real conversion. Read result.Bytes rather than Text() for those: EPL’s GW and TSPL’s BITMAP commands inline raw binary.

Each record produces one label:

Options: &labelzoom.Options{
Data: []labelzoom.DataRecord{
{"name": "ACME Corp", "sku": "12345"},
{"name": "Globex", "sku": "67890"},
},
}

The free tier returns the first label only, so this is a 1-page PDF until a key is configured. A single record may be passed on its own — it is wrapped rather than rejected.

Every non-2xx becomes a typed error carrying the status, the message, the raw body, and the X-LZ-Request-Id support handle:

result, err := client.Convert(ctx, request)
var forbidden *labelzoom.ForbiddenError
if errors.As(err, &forbidden) && forbidden.IsPaidFeature {
// "JSON export is a paid feature" — the most common free-tier refusal.
}
var apiErr *labelzoom.APIError
if errors.As(err, &apiErr) {
log.Printf("request %s failed with %d: %s", apiErr.RequestID, apiErr.Status, apiErr.Message)
}

*BadRequestError, *UnauthorizedError, *ForbiddenError, *NotFoundError, *PayloadTooLargeError, *RateLimitedError and *ServerError all unwrap to *APIError, so one errors.As catches the lot.

*ValidationError deliberately does not: it reports a request rejected locally, before any network call — a bug in the calling code rather than a server response. Catching *APIError for fallback behaviour will not swallow it.

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.

Both seams the retry loop needs are exported, so a test never opens a socket and never sleeps:

client, _ := labelzoom.New(
labelzoom.WithHTTPClient(&http.Client{Transport: stub}),
labelzoom.WithSleeper(func(d time.Duration) { slept = append(slept, d) }),
labelzoom.WithoutJitter(),
labelzoom.WithEnvLookup(func(string) (string, bool) { return "", false }),
)

WithEnvLookup is worth using even when a test does not care about credentials: without it, a developer’s real LABELZOOM_API_KEY changes what the SDK sends.

The SDK is a wrapper over one HTTP endpoint; net/http works just as well.

zpl := "^XA^FO50,50^ADN,36,20^FDLabelZoom^FS^XZ"
params := url.Values{}
params.Set("params", `{"label":{"width":4,"height":6},"dpi":203}`)
endpoint := "https://api.labelzoom.com/api/v2/convert/zpl/to/pdf?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(zpl))
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Accept", "*/*")
if key := os.Getenv("LABELZOOM_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("LabelZoom returned %d: %s", resp.StatusCode, body)
}
return os.WriteFile("label.pdf", body, 0o600)

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.