zplCore.comzplCore.com

Documentation · zplCore Server

zplCore Server
templates and data in, labels out.

zplCore Server is the zplCore.com engine as a ready-to-run container with a REST API. The label templates live on the server; your application sends only the data – or the server fetches it itself through a named database or REST query – and gets ZPL, PNG, SVG, PDF, a ZIP or a JSON report back, or the labels go straight to a network printer. One container, configured with one YAML file or environment variables, no code. This page covers the quick start, every configuration key, all endpoints, template and data sources, printing, storage, jobs and webhooks, security and operations.

Quick start Endpoints Configuration Product page

01Overview

Not published yet – available with version 1.0. The image will be published on Docker Hub as zplcore/server with version tags such as 1.0.0; there is no date yet. The API, the configuration and the behaviour on this page were checked against the current pre-release of the server; the Docker commands describe the image as it is built for 1.0. Names and defaults can still change until then. More on the product page.

How it works

How zplCore Server works Templates from a volume, Git, S3, Azure Blob, Google Cloud Storage or HTTP and data from the request, a database or a REST service go into zplCore Server. Out come ZPL, PNG, SVG, PDF, ZIP or JSON as the response, a print job on a network printer, a stored file and a signed webhook. Templates Volume · ConfigMap · Git S3 / MinIO · Azure Blob · GCS · HTTP Data JSON · NDJSON · CSV in the request SQL and REST: named queries zplCore Server REST API /v1 · template + records → labels container · NativeAOT · zplcore.yaml Response ZPL · PNG · SVG · PDF · ZIP · JSON Printer TCP 9100 · checked by the printer twin Storage volume · S3 · Azure Blob · GCS Webhook signed, when a job ends
Templates and data in; the labels come back in the response, go to a printer, into storage – and a webhook reports the result.
  • Templates are the same JSON files as in the SDK: label JSON (Label.ToJson), print jobs with start and end label (PrintJobTemplate) or documents of the zplCore Designer. They are called by path, e.g. shipping/label-100x150, and versioned by their content.
  • Data comes with the request (JSON, NDJSON, CSV) or from a named query the server runs read-only against a database or a REST service. Fields, expressions, counters and formats work as in the SDK.
  • Outputs: ZPL is written as a stream, record by record – also for 100,000 labels and more; PNG, SVG, PDF and ZIP are rendered by the zplCore.com renderer; a JSON report gives counts and the SHA-256. The labels can also go to a configured printer, into storage and trigger a webhook – synchronously or as an asynchronous job.
  • Operation: no state of its own, health and readiness probes, Prometheus metrics, OpenTelemetry, OpenAPI with interactive API docs – and an offline licence key without phone-home.

Server or SDK?

zplCore SDK (NuGet)zplCore Server (container)
Runsin your .NET applicationas its own service, called over HTTP from any language
Templatesloaded by your codeon the server: volume, Git, S3, Azure Blob, GCS, HTTP – reloaded and versioned
Datayour objects, EF Core, LiteDB …JSON, NDJSON, CSV or named SQL/REST queries – no code
Printingyour code (TCP, USB, spooler, CUPS)configured network printers (TCP 9100) with queue and printer twin
Suits.NET teams who want full controlteams without .NET, central label services, Kubernetes

02Quick start

Images and tags

Tag (planned)BuildPlatformsWhen
zplcore/server:1.0.0NativeAOT – one binary, starts fast, smalllinux/amd64, linux/arm64the default
zplcore/server:1.0.0-fullJIT (.NET runtime)linux/amd64, linux/arm64your own Oracle driver; SQL Server with Entra ID, integrated security or Always Encrypted
zplcore/server:1.0.0-arm32v7JITlinux/arm/v732-bit ARM (NativeAOT has no 32-bit ARM)

All images listen on port 8080, run as the non-root user app (UID 1654) and contain no shell. The default configuration reads templates from /templates.

First labels

Put a template into ./templates: the file templates/shipping/label-100x150.json is called as shipping/label-100x150. The authentication mode is required – there is no default, and without it the container stops at once (exit code 2) with a message that names ZPLCORE__AUTH__MODE.

# local test without authentication
docker run --rm -p 8080:8080 \
  -v ./templates:/templates:ro \
  -e ZPLCORE__AUTH__MODE=none \
  zplcore/server:1.0.0

# data in, ZPL out (Accept: application/vnd.zpl is the default)
curl -X POST localhost:8080/v1/labels/shipping/label-100x150 \
  -H 'Content-Type: application/json' \
  -d '{"Name":"Anna Huber","City":"1010 Wien"}'

# one label as PNG, several as one PDF
curl -X POST localhost:8080/v1/labels/shipping/label-100x150 -H 'Accept: image/png' \
  -H 'Content-Type: application/json' -d '{"Name":"Anna Huber"}' -o label.png
curl -X POST localhost:8080/v1/labels/shipping/label-100x150 -H 'Accept: application/pdf' \
  -H 'Content-Type: application/json' -d '[{"Name":"Anna Huber"},{"Name":"Max Muster"}]' -o labels.pdf

# report: counts, size and SHA-256 of the ZPL
curl -X POST localhost:8080/v1/labels/shipping/label-100x150 -H 'Accept: application/json' \
  -H 'Content-Type: application/json' -d '[{"Name":"Anna Huber"},{"Name":"Max Muster"}]'
# {"template":"shipping/label-100x150","version":"61581b863bd9","records":2,"labels":2,"zplBytes":3259,"sha256":"…","license":"evaluation"}

In production use API keys (at least 16 characters) or JWT (see Authentication):

docker run --rm -p 8080:8080 -v ./templates:/templates:ro \
  -e ZPLCORE__AUTH__MODE=apikey -e ZPLCORE__AUTH__APIKEYS__0=<key> \
  zplcore/server:1.0.0

curl -X POST localhost:8080/v1/labels/shipping/label-100x150 -H 'X-Api-Key: <key>' \
  -H 'Content-Type: application/json' -d '{"Name":"Anna Huber"}'

Your own zplcore.yaml is mounted at /config/zplcore.yaml; http://localhost:8080/docs shows the interactive API reference.

Licence key

The server checks the same offline licence key as the SDK (Licence key) – locally, never over the network. It looks in this order:

  1. ZPLCORE__LICENSE__KEY – the key itself as an environment variable,
  2. the file license:file, default /run/secrets/zplcore-license (a Docker or Kubernetes secret) – checked every 30 seconds, so a renewed key applies without a restart,
  3. the SDK's own sources: ZPLCORE_LICENSE, ZPLCORE_LICENSE_FILE, a file zplcore.license next to the binary.
docker run … -v ./zplcore.license:/run/secrets/zplcore-license:ro zplcore/server:1.0.0
# or: -e ZPLCORE__LICENSE__KEY=…   or a secret "zplcore-license" in Docker Swarm / Kubernetes

Without a valid key the server runs in evaluation mode, without a time limit: everything works, but the outputs are marked – ZPL with several small zplCore.com TRIAL fields per label, PNG and PDF with a watermark, SVG only as an image. Answers of the label endpoints carry X-ZplCore-License: licensed or evaluation, the JSON report the field license; /ready shows the state, edition and end of maintenance to authenticated callers (never the key or the licensee). A damaged key does not stop the server; it runs in evaluation mode and logs why.

03Configuration: zplcore.yaml

The server reads one configuration file, the first one found:

  1. --config <file> on the command line,
  2. the environment variable ZPLCORE_CONFIG,
  3. /config/zplcore.yaml (mount your own there),
  4. the zplcore.yaml next to the binary – the image's default (volume source /templates, docs on, metrics off).

Every key can also be set as an environment variable ZPLCORE__SECTION__KEY, which wins over the file: ZPLCORE__AUTH__MODE=apikey, ZPLCORE__LIMITS__MAXRENDERLABELS=500; list entries and named entries with their index or name: ZPLCORE__AUTH__APIKEYS__0, ZPLCORE__TEMPLATES__SOURCES__0__PATH, ZPLCORE__WEBHOOKS__ERP__SECRET. Variables without the prefix ZPLCORE__ are not read as configuration (apart from the host's own ASPNETCORE_…/DOTNET_… and OTEL_…). Durations are written as hh:mm:ss (00:00:30), sizes in bytes. The file is read at start-up; templates and the licence file are reloaded while the server runs. A missing, invalid or incomplete setting stops the start with exit code 2 and a message that names the key.

Secrets

API keys, the licence key, the metrics key, database connection strings, REST tokens, passwords and header values, cloud keys (secret access key, session token, storage connection string, SAS token, bearer token, basic auth), webhook keys and the Redis connection string are never written into zplcore.yaml. Each has a …File key (a Docker or Kubernetes secret, e.g. connectionStringFile: /run/secrets/wms-db; the line break at the end is removed) or comes from the environment (ZPLCORE__DATA__CONNECTIONS__WMS__CONNECTIONSTRING). A plain value in the file stops the start with exit code 2 and names the key and the alternatives; a value and a file for the same key at once is an error as well. Secrets never appear in logs, error answers, /ready or the catalogues.

Exception – the sign-in chains of the cloud platforms. When no keys are configured, the cloud sources and sinks use the standard variables the platform sets: AWS – AWS_REGION/AWS_DEFAULT_REGION and the AWS default chain (AWS_ACCESS_KEY_ID …, EKS IRSA/Pod Identity, ECS and EC2 roles); Google – GOOGLE_APPLICATION_CREDENTIALS and the metadata server (GCE_METADATA_HOST, GKE Workload Identity, Cloud Run); Azure with identity: managed or workload – IDENTITY_ENDPOINT/IDENTITY_HEADER, AZURE_POD_IDENTITY_AUTHORITY_HOST, AZURE_FEDERATED_TOKEN_FILE, AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_AUTHORITY_HOST. These carry only credentials and platform endpoints, no server settings.

Reference of all keys

“env only” = the value only from an environment variable (or the matching …File key), never in the file. Empty default = not set.

auth – authentication

KeyDefaultMeaning
moderequiredapikey, jwt (also oidc) or none
apiKeyHeaderX-Api-Keyheader that carries the API key
apiKeysenv only: ZPLCORE__AUTH__APIKEYS__0, __1 …; at least 16 characters each
apiKeysFilefile with one key per line, # starts a comment
authorityOIDC issuer (mode jwt, required)
audienceexpected audience of the token (mode jwt, required)
requiredRolerole the token must carry (claim roles or role)
requireHttpsMetadatatruefetch the OIDC metadata over https only

license

KeyDefaultMeaning
file/run/secrets/zplcore-licensefile with the licence key, checked every 30 s
keyenv only: ZPLCORE__LICENSE__KEY

templates

KeyDefaultMeaning
sourcesimage: volume /templateslist of template sources in priority order (keys below)
keepVersions10earlier versions kept per template (callable as path@version)
inline:enabledfalseenables POST /v1/render (template in the request)

Keys of one source (templates:sources:N):

KeyDefaultTypesMeaning
nameallname of the source (letters, digits, ., _, -); name:path asks this source
typevolumeallvolume, s3, azureblob, gcs, http
pollvolume 10 s, others 30 sallpolling interval
timeout00:00:30remotelongest time of one request to the source
pathvolumefolder, mounted read-only
bucket / containers3, gcs / azureblobbucket or container
prefixs3, azureblob, gcskey prefix, e.g. prod/: prod/shipping/x.json → shipping/x
serviceUrlthe cloud services3, azureblob, gcsS3-compatible endpoint (MinIO), Azure account URL, other GCS endpoint
maxObjects10000s3, azureblob, gcsmost objects one listing may return (0 = no limit)
regionAWS_REGIONs3AWS region; with serviceUrl the signing region (default us-east-1)
pathStylefalses3path-style requests (MinIO and most S3-compatible stores)
accessKeyId / accessKeyIdFiles3access key id; without keys the AWS default chain
secretAccessKey / secretAccessKeyFiles3secret access key (value env only)
sessionToken / sessionTokenFiles3session token of temporary credentials (value env only)
connectionString / connectionStringFileazureblobstorage connection string (value env only)
sasToken / sasTokenFileazureblobSAS token for serviceUrl (value env only)
identityazureblobmanaged (managed identity) or workload (AKS Workload Identity), with serviceUrl
clientId / tenantIdAZURE_CLIENT_ID / AZURE_TENANT_IDazureblobuser-assigned identity or workload identity
credentialsFileGOOGLE_APPLICATION_CREDENTIALSgcsservice account key (JSON); without it the metadata server
anonymousfalsegcsno authentication (emulator, public bucket)
baseUrlhttpbase URL; shipping/x is <baseUrl>shipping/x.json
allowedHostshost of baseUrlhttphosts the source may request, redirects included
allowedNetworkshttpnetworks (CIDR) that may be reached although private, loopback or link-local
allowHttpfalsehttpallow plain http
bearerToken / bearerTokenFilehttpbearer token (value env only)
basicAuth / basicAuthFilehttpuser:password (value env only)

limits

KeyDefaultMeaning
maxRequestBodyBytes268435456 (256 MiB)largest request body (413)
maxRecordsPerRequest1000000records per request (also all parts of a batch together)
maxRenderLabels1000labels per request for PNG, SVG, PDF, ZIP and JSON with diagnostics (413)
maxTemplateBytes8388608 (8 MiB)largest template file
maxConcurrentRenders0 = processorsrender slots
maxQueuedRenders64requests waiting for a slot; more: 429
queueTimeout00:00:30longest wait for a slot; then 429
requestTimeout00:10:00longest time of one request (504)
rateLimitPermits0 = offrequests per window and client
rateLimitWindow00:01:00window of the rate limit
maxLabelSide8000largest label side in dots (also ^PW/^LL)
maxFieldDataLength65536characters of one field
maxImagePixels40000000largest image (pixels) an image, QR logo or barcode art decodes; 0 = no limit
maxImageBytes10485760 (10 MiB)largest raster image of a template or record; 0 = no limit
maxSvgBytes2097152 (2 MiB)largest SVG of a template or record; 0 = no limit

output

KeyDefaultMeaning
defaultDpi203resolution when the request names none (100–1200)
storedFormatMinRecords1000from this many records (count known in advance) the record label is stored once in the printer (^DF/^XF, prints the same); 0 = never
storedFormatNameR:ZPLCORE.ZPLname of that stored format
fontFoldersfolders with TTF/OTF files templates may name (Fonts)
fontsgraphicgraphic or printer: how text in TrueType fonts reaches the printer (Fonts)
generationThreads0 = automaticthreads that build the ZPL of one request with records in memory: processors in the NativeAOT image, 1 under JIT; 1 = serial

docs, metrics

KeyDefaultMeaning
docs:enabledtrue/docs (Scalar) and /openapi/v1.json
metrics:enabledfalse/metrics in the Prometheus format
metrics:portown port for /metrics (e.g. 9464), without further check
metrics:apiKeyenv only: key that opens /metrics on the main port

data – named queries

data:connections:<name>:

KeyDefaultMeaning
providersqlite, postgresql, sqlserver, mysql (also MariaDB), oracle (-full image with your driver) or rest
requiredfalse/ready answers 503 while the connection is unreachable
connectionString / connectionStringFiledatabases (value env only)
baseUrlREST: base address; query paths are relative to it
allowedHostshost of baseUrlREST: further hosts for paging links and redirects
allowedNetworksREST: networks (CIDR) that may be reached although private, loopback or link-local
allowHttpfalseREST: allow plain http
auth:typenoneREST: bearer, basic, header
auth:token / auth:tokenFilebearer token (value env only)
auth:username, auth:password / auth:passwordFilebasic (password env only)
auth:header, auth:value / auth:valueFileown header, e.g. X-Api-Key (value env only)

data:queries:<name>:

KeyDefaultMeaning
connectionname of the connection
sqldatabases: one SELECT (or WITH … SELECT); parameters @name (Oracle :name)
countSqldatabases, optional: the number of rows of sql (same parameters)
pathREST: path and query relative to baseUrl; {name} = URL-encoded parameter
items$REST: JSON path to the array of records, e.g. $.items
paging:nextREST: link (Link header rel="next") or a JSON path like $.next
paging:maxPages100REST: most pages of one call
parameters:<name>:typestringstring, int, long, decimal, double, bool, date (yyyy-MM-dd), datetime (ISO 8601)
parameters:<name>:requiredfalsethe parameter must be given
parameters:<name>:patternstrings: regular expression for the whole value
maxRows0 = limits:maxRecordsPerRequestmost rows of one call; more end the request with an error
timeout00:00:30longest time of one call (all rows, all pages)
maxBytes0 = limits:maxRequestBodyBytesmost data of one call

printers, printing

printers:<name>:

KeyDefaultMeaning
hosthost name or IP address of the printer
port9100raw TCP port
modelmodel, e.g. ZD421; with dpi it selects the printer twin profile
profilefrom model + dpi, else genericprinter twin profile, e.g. ZD421d-203
dpiprofile, else output:defaultDpiresolution the labels are generated at
optionsinstalled options for the twin: cutter, peeler, rewinder, rfid, linerless, applicator, realtimeclock
twinwarnoff, warn or block
fontsoutput:fontsgraphic or printer for this printer
timeout00:00:10connect and ~HS
sendTimeout00:01:00longest time without progress while sending
retries2further attempts – only before the first byte
retryDelay00:00:01wait before the first retry, doubled for each further one
queueLimit100jobs waiting for this printer; more: 503
checkStatusfalseask ~HS before every job; not ready = not sent, retried

printing:twinSampleLabels (20): labels of a job the printer twin checks before sending. printing:maxCopies (1000): upper limit of ?copies= (also copies × a copies field of the template).

sinks, webhooks

sinks:<name>: type (volume; also s3, azureblob, gcs), path (volume), pattern ({date}/{jobId}.{ext}; placeholders {date}, {time}, {jobId}, {template}, {ext}). Cloud sinks take the keys of the template source of the same type (bucket/container, prefix, serviceUrl, region, pathStyle, credential files, identity); secrets as there, e.g. ZPLCORE__SINKS__ARCHIVE__SECRETACCESSKEY.

webhooks:<name>:DefaultMeaning
urlreceiver (https)
secretFile / secretsigning key, at least 16 characters (value env only: ZPLCORE__WEBHOOKS__<NAME>__SECRET)
timeout00:00:10per attempt
retries5further attempts after network errors, timeouts, 408, 429, 5xx
retryDelay00:00:02first wait, doubled each time (at most 5 minutes)
allowHttpfalseallow plain http
allowPrivateNetworksfalseallow private networks and loopback
allowedNetworksnetworks (CIDR) allowed although blocked otherwise (also link-local)

jobs, batch

KeyDefaultMeaning
jobs:enabledtrueasynchronous jobs and /v1/jobs
jobs:workers0 = processorsjobs running at the same time on this instance
jobs:queueCapacity100jobs waiting on this instance; more: 429
jobs:sinksink for job status and results (jobs/{id}/…); without it: memory, one instance
jobs:ttl24:00:00how long status and result are kept
jobs:cleanupInterval00:10:00how often expired jobs are deleted
jobs:spoolPathtemp folderlocal folder for request bodies and outputs while a job runs
jobs:redis:connectionString / connectionStringFilejob status additionally in Redis (needs jobs:sink; value env only)
jobs:redis:keyPrefixzplcore:jobs:prefix of the Redis keys
batch:maxParts1000parts of one batch

Complete example

auth:
  mode: apikey
  apiKeysFile: /run/secrets/zplcore-api-keys      # one key per line

license:
  file: /run/secrets/zplcore-license

templates:
  sources:
    - name: local
      type: volume
      path: /templates
    - name: shared
      type: s3
      bucket: labels
      prefix: prod/
      region: eu-central-1                         # no keys: AWS default chain (IRSA, ECS/EC2 role)
  keepVersions: 10

limits:
  maxRenderLabels: 1000
  requestTimeout: 00:10:00
  rateLimitPermits: 600
  rateLimitWindow: 00:01:00

output:
  defaultDpi: 203
  fontFolders: [/fonts]
  fonts: graphic

metrics:
  enabled: true
  port: 9464

data:
  connections:
    wms:
      provider: postgresql
      connectionStringFile: /run/secrets/wms-db
      required: true
  queries:
    open-shipments:
      connection: wms
      sql: >
        select tracking_no as "TrackingId", name as "Name", city as "City", ship_date as "ShipDate"
        from shipments where country = @country and status = 'open' order by tracking_no
      countSql: select count(*) from shipments where country = @country and status = 'open'
      parameters:
        country: { type: string, required: true, pattern: "[A-Z]{2}" }

printers:
  lager-1:
    host: 10.0.5.21
    model: ZD421
    dpi: 203
    twin: block

sinks:
  out:
    type: volume
    path: /out

webhooks:
  erp:
    url: https://erp.example.com/hooks/labels
    secretFile: /run/secrets/erp-webhook

jobs:
  sink: out

04Authentication and rate limit

ModeHowFor
apikeykey in the header X-Api-Key (auth:apiKeyHeader); keys from ZPLCORE__AUTH__APIKEYS__0, __1 … or auth:apiKeysFile, at least 16 characters, compared as SHA-256 in fixed timeservice-to-service calls
jwt (oidc)bearer token of your identity provider; auth:authority and auth:audience are required (without an audience the server does not start: tokens of the same issuer for other applications would pass); optional auth:requiredRoleEntra ID, Keycloak, Auth0 …
noneno check – everybody who reaches the server may call it; a warning in the loglocal tests, or behind your own gateway that authenticates
# JWT
-e ZPLCORE__AUTH__MODE=jwt \
-e ZPLCORE__AUTH__AUTHORITY=https://login.example.com/realms/labels \
-e ZPLCORE__AUTH__AUDIENCE=zplcore-server \
-e ZPLCORE__AUTH__REQUIREDROLE=label-printer

curl … -H "Authorization: Bearer $TOKEN"

Without authentication: /health, /ready (status only), /docs, /openapi/v1.json. Everything below /v1 requires it; a missing or wrong key answers 401 without a body. /metrics has its own rule (Metrics).

Rate limit: with limits:rateLimitPermits > 0 every client may send that many requests per limits:rateLimitWindow to /v1; further requests get 429 without a body. A client is the authenticated identity – the API key, or the JWT subject (sub, else azp/client_id) – otherwise the IP address; never a header the client can choose freely.

05Endpoints

All endpoints below /v1 need authentication. Answers are JSON with camelCase names; errors are application/problem+json in English (Errors). The interactive reference of the running server is at /docs, the OpenAPI document at /openapi/v1.json.

MethodPathPurpose
POST/v1/labels/{template}fill a template with records, return the labels (or print, store, notify)
POST/v1/renderthe same with the template in the request (off by default)
GET/v1/templatescatalogue: templates, versions, errors, sources
GET/v1/templates/{path}, …/previewone template as JSON; PNG of its first label
GET/v1/queriesnamed queries with their parameters
POST/v1/print/{printer}print ZPL, or a template with records
GET/v1/printers, /v1/printers/{name}/statusprinters with queue; live status (~HS)
POST · GET · DELETE/v1/jobs, /v1/jobs/{id}, /v1/jobs/{id}/resultasynchronous jobs
POST/v1/batchseveral templates and record sets in one request
GET/health, /ready, /metrics, /docs, /openapi/v1.jsonoperation

POST /v1/labels/{template}

{template} is the template path, optionally with source and version: shipping/label-100x150, shared:shipping/label-100x150, shipping/label-100x150@61581b863bd9. The body holds the records (formats); the Accept header selects the output:

AcceptAnswer
application/vnd.zpl (default; also text/plain, */*)ZPL, streamed record by record
image/png, image/svg+xmlexactly one label; several → 406
application/pdfone PDF, one page per label
application/zipone image per label (label00001.png …), written while rendering
application/jsonreport { template, version, records, labels, zplBytes, sha256, license, diagnostics?, zpl? }
Query parameterMeaning
dpi100–1200, default output:defaultDpi
versiontemplate version (content hash or the source's version id)
formatZIP entries: png (default) or svg
diagnosticstrue: the JSON report renders the labels and lists the renderer's diagnostics
zpltrue: the JSON report includes the ZPL
copiesprints every label that often (^PQ), 1 to printing:maxCopies
fontsgraphic or printer (Fonts)
delimiterCSV separator: , ; tab | (default: detected)
query + its parametersrecords from a named query, e.g. ?query=open-shipments&country=AT
print, twinsend to a configured printer; twin check off/warn/block
store, webhookwrite to a sink; notify a webhook
asynctrue (or header Prefer: respond-async): run as a job, answer 202
curl -i -X POST localhost:8080/v1/labels/shipping/label-100x150 \
  -H 'X-Api-Key: <key>' -H 'Content-Type: application/json' \
  -d '{"Name":"Anna Huber","City":"1010 Wien"}'
HTTP/1.1 200 OK
Content-Type: application/vnd.zpl; charset=utf-8
X-Template-Version: 61581b863bd9
X-ZplCore-License: licensed
X-Record-Count: 1

^XA
^CI28
^PW799
^LL1199
…

Response headers: X-Template-Version (and X-Template-Source-Version when the source has its own version id), X-ZplCore-License, X-Record-Count when the number is known in advance; PDF and ZIP with Content-Disposition, SVG with Content-Security-Policy: sandbox. With print, store, webhook or async the answer is the job status instead. Errors: 400, 401, 404, 406, 413, 415, 422, 429, 502, 504.

POST /v1/render

Only with templates:inline:enabled: true. Body { "template": { … }, "records": [ … ] } – the template is a label, print job or Designer document; without records one empty record. Outputs and the parameters dpi, format, diagnostics, zpl, fonts as for /v1/labels. An inline template never reads files on the server: an image with sourceFile answers 400 – send the image in imageData; fonts must be embedded in the template.

curl -X POST localhost:8080/v1/render -H 'X-Api-Key: <key>' -H 'Accept: image/png' \
  -H 'Content-Type: application/json' -o label.png -d '{
  "template": { "unit": "Mm", "width": 50, "height": 30,
                "items": [ { "type": "text", "dataField": "Name", "font": "0", "fontSize": 10, "x": 2, "y": 2 } ] },
  "records": [ { "Name": "Anna" } ] }'

GET /v1/templates, /v1/templates/{path}

GET /v1/templates lists every template with its kept versions, the files that could not be loaded (with the reason) and the sources (location without credentials, loaded, count, last synchronisation, revision).

{ "templates": [ { "path": "shipping/label-100x150", "source": "local", "kind": "label",
                   "version": "35ad7214ed79", "size": 4095, "loaded": "2026-10-25T08:16:19Z", "hasSampleRecords": false,
                   "versions": [ { "version": "35ad7214ed79", "sha256": "35ad7214ed79…", "size": 4095, "loaded": "…" },
                                 { "version": "61581b863bd9", "sha256": "61581b863bd9…", "size": 4095, "loaded": "…" } ] } ],
  "errors": [ { "path": "shipping/broken", "source": "local",
                "message": "not a zplCore template: expected a label (\"items\"), a print job (\"label\") or a Designer document",
                "time": "2026-10-25T08:16:19Z" } ],
  "sources": [ { "name": "local", "type": "volume", "location": "/templates", "loaded": true, "templates": 2, "lastSync": "…" } ] }

GET /v1/templates/{path} returns the template JSON (of a Designer document its label) with X-Template-Version; ?version= or @version selects a version. GET /v1/templates/{path}/preview returns a PNG of the first label with the template's sample records (Designer documents) or an empty record. Errors: 404, 413, 422, 429, 502.

GET /v1/queries

The configured named queries with their parameters – never SQL, URL or connection:

{ "queries": [ { "name": "open-shipments", "kind": "database",
                 "parameters": [ { "name": "country", "type": "string", "required": true, "pattern": "[A-Z]{2}" } ],
                 "maxRows": 100000, "counted": true } ] }

POST /v1/print/{printer}

Prints on a configured printer. The body is either ZPL (application/vnd.zpl, text/plain, application/octet-stream), sent as it is, or – with ?template= – the records (JSON, NDJSON, CSV), generated at the printer's dpi. Parameters: template, version, query (+ parameters), delimiter, dpi, copies and fonts (only with template), twin, store, webhook, async. The answer is the job status: 200 when sent, or a problem whose instance is the status URL.

curl -X POST 'localhost:8080/v1/print/lager-1?template=shipping/label-100x150&copies=2' \
  -H 'X-Api-Key: <key>' -H 'Content-Type: application/json' \
  -d '[{"Name":"Anna Huber"},{"Name":"Max Muster"}]'
{ "id": "25c9fad6a1774325bec2a1dc59c2c45c", "state": "succeeded", "kind": "labels", "synchronous": true,
  "template": "shipping/label-100x150", "templateVersion": "35ad7214ed79", "format": "zpl", "dpi": 203,
  "copies": 2, "records": 2, "labels": 2, "bytes": 3263, "sha256": "cde41d94…",
  "print": { "printer": "lager-1", "state": "sent", "profile": "ZD421d-203", "attempts": 1, "bytesSent": 3263,
             "twin": { "profile": "ZD421d-203", "mode": "warn", "errors": 0, "warnings": 0, "checkedLabels": 2, "findings": [] } } }
{ "type": "https://tools.ietf.org/html/rfc9110#section-15.6.3", "title": "Printing failed", "status": 502,
  "detail": "Printer 'lager-2' (10.0.5.22:9100) not reachable after 2 attempt(s): connection refused. Nothing was sent.",
  "instance": "/v1/jobs/2a261464c0494a1fb49b8247a329f570", "traceId": "00-85bb6f52…-00" }

Errors: 400, 404 (printer, template), 409, 415, 422 (twin block, copies), 429, 502 (printer not reachable), 503 (queue full).

GET /v1/printers, /v1/printers/{name}/status

[ { "name": "lager-1", "host": "10.0.5.21", "port": 9100, "profile": "ZD421d-203", "model": "ZD421d", "dpi": 203,
    "twin": "warn", "checkStatus": false,
    "queue": { "waiting": 0, "busy": false, "sent": 42, "failed": 0 } } ]

/status asks the printer over its own connection (~HS); while the printer sends another connection's job it answers afterwards. 502 = not reachable, 504 = no complete answer within timeout.

{ "printer": "lager-1", "ready": true, "paperOut": false, "paused": false, "headOpen": false, "ribbonOut": false,
  "overTemperature": false, "underTemperature": false, "bufferFull": false, "corruptRam": false, "partialFormat": false,
  "formatsInBuffer": 0, "labelsRemaining": 0, "labelLengthDots": 1245, "printMode": "tear-off",
  "queue": { "waiting": 0, "busy": false, "sent": 42, "failed": 0 } }

/v1/jobs

RequestAnswer
POST /v1/jobs202, Location: /v1/jobs/{id}, the status; 400 (unknown property), 404, 429 (queue full), 503 (status could not be stored)
GET /v1/jobs/{id}the status; 404 unknown or expired
GET /v1/jobs/{id}/resultthe output with ETag = SHA-256; 409 while the job has not succeeded; 404 when it was a synchronous delivery (only its status is kept)
DELETE /v1/jobs/{id}202 = a waiting or running job is cancelled (also on another instance); 204 = a finished job is deleted

Job document of POST /v1/jobs – unknown properties are rejected:

PropertyMeaning
template, versiontemplate path and version
records or queryrecords (array or one object), or { "name": …, "parameters": { … } }
variablesjob variables (start and end label)
zplZPL to print or store as it is (instead of a template)
partsa batch: [ { "template", "records", "variables", "version", "copies" }, … ]
format, zipEntrieszpl (default), png, svg, pdf, zip, json; ZIP entries png/svg (batch zpl/pdf)
dpi, copies, fonts, diagnostics, includeZplas the query parameters of /v1/labels
print, twin, store, webhookprinter, twin mode, sink, webhook by name
curl -i -X POST localhost:8080/v1/jobs -H 'X-Api-Key: <key>' -H 'Content-Type: application/json' \
  -d '{"template":"shipping/label-100x150","query":{"name":"open-shipments","parameters":{"country":"AT"}},
       "format":"pdf","store":"out","webhook":"erp"}'
# HTTP/1.1 202 Accepted
# Location: /v1/jobs/6fb805d73e1e40f8aff8b6290fc62360

curl localhost:8080/v1/jobs/6fb805d73e1e40f8aff8b6290fc62360 -H 'X-Api-Key: <key>'
curl localhost:8080/v1/jobs/6fb805d73e1e40f8aff8b6290fc62360/result -H 'X-Api-Key: <key>' -o labels.pdf
{ "id": "6fb805d73e1e40f8aff8b6290fc62360", "state": "succeeded", "kind": "labels", "synchronous": false,
  "created": "2026-10-25T08:18:20Z", "started": "2026-10-25T08:18:20Z", "finished": "2026-10-25T08:18:21Z",
  "expires": "2026-10-26T08:18:20Z", "template": "shipping/label-100x150", "templateVersion": "35ad7214ed79",
  "format": "pdf", "contentType": "application/pdf", "dpi": 203, "records": 2, "labels": 2, "bytes": 188985,
  "sha256": "cb1e2c80…", "result": "/v1/jobs/6fb805d73e1e40f8aff8b6290fc62360/result",
  "store": { "sink": "out", "key": "2026-10-25/6fb805d73e1e40f8aff8b6290fc62360.pdf", "location": "file:///out/2026-10-25/…pdf" },
  "webhook": { "name": "erp", "state": "delivered", "attempts": 1, "lastStatus": 204 }, "instance": "zplcore-server-7c9f" }

States: queued, running, succeeded, failed, canceled; a failed job carries error { status, title, detail } – the problem a synchronous request would have got.

POST /v1/batch

Several templates and record sets in one request. Body application/json { "parts": [ … ] } (or the array itself) – all parts are checked before the first byte – or application/x-ndjson, one part per line, read part by part. A part: { "template", "records", "variables"?, "version"?, "copies"? }. Accept: application/vnd.zpl (default; one stream, parts in order), application/zip (one entry per part, ?format=zpl or pdf) or application/json (report per part). Parameters: dpi, copies (default of the parts), fonts, print, twin, store, webhook, async.

curl -X POST localhost:8080/v1/batch -H 'X-Api-Key: <key>' -H 'Accept: application/json' \
  -H 'Content-Type: application/json' -d '{"parts":[
    {"template":"shipping/label-100x150","records":[{"Name":"A"}]},
    {"template":"shipping/label-100x150","records":[{"Name":"B"},{"Name":"C"}],"copies":2}]}'
{ "parts": 2, "records": 3, "labels": 3, "zplBytes": 4860, "license": "licensed",
  "items": [ { "template": "shipping/label-100x150", "version": "35ad7214ed79", "records": 1, "labels": 1, "zplBytes": 1611, "sha256": "51c03bcc…", "license": "licensed" },
             { "template": "shipping/label-100x150", "version": "35ad7214ed79", "records": 2, "labels": 2, "zplBytes": 3249, "sha256": "95a6c359…", "license": "licensed" } ] }

Limits: batch:maxParts, limits:maxRecordsPerRequest for all parts together, limits:maxRenderLabels for PDF entries. ZIP entries are named part00001-shipping_label-100x150.pdf … Errors as for /v1/labels; a part names its number (“Part 1: template … does not exist”).

Health, metrics, OpenAPI

PathAnswer
GET /healthok – liveness, anonymous
GET /ready200 {"status":"ready"} or 503 {"status":"not-ready"}; with authentication the details below
GET /metricsPrometheus text format; off by default (Metrics)
GET /docsinteractive API reference (Scalar), served by the server itself – no CDN
GET /openapi/v1.jsonOpenAPI document
{ "status": "ready", "version": "1.0.0", "buildDate": "2026-10-25",
  "license": { "origin": "/run/secrets/zplcore-license",
               "sdk": { "state": "Licensed", "edition": "team", "maintenanceEnd": "2027-10-23", "watermark": false },
               "designer": { "state": "ProductNotLicensed", "watermark": true }, "message": "licensed" },
  "templates": { "count": 2, "errors": 0, "sources": [ "local" ] }, "renderer": "ok", "problems": [],
  "delivery": { "jobs": { "enabled": true, "mode": "storage", "singleInstance": false, "sink": "out", "queued": 0, "running": 0, "workers": 4 },
                "printers": 2, "sinks": 1, "webhooks": 1, "webhookDeliveriesPending": 0, "webhookDeliveries": "storage" } }

Errors

Errors are application/problem+json (RFC 9457) with title, status, detail and usually a traceId – texts in English. 401, 415 and the rate-limit 429 have no body. A server error (500) never contains internal details: the log has them under the traceId.

{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", "title": "Template not found", "status": 404,
  "detail": "Template 'shipping/label-100x150@000000000000' has no version '000000000000' (GET /v1/templates lists the kept versions).",
  "traceId": "00-2242f4fea071d5da2edf6b57535508b3-8df666579c107ddc-00" }
StatusTypical titleCause
400Invalid records · Invalid dpi · Invalid copies · Invalid query parameters · Local files not allowedJSON/CSV/NDJSON not readable, a parameter out of range, a pattern not matched, sourceFile in an inline template
401–no or wrong API key / token
404Template not found · Query not found · Printer not found · Sink not found · Webhook not found · Job not foundunknown name, version or expired job
406Unsupported Accept header · More than one labelAccept not supported; PNG/SVG for several labels; ZIP when printing
409Job not finished · Job did not succeedresult requested too early or of a failed job
413Too many labels · Image too largelimits:maxRenderLabels; an image above maxImagePixels/maxImageBytes/maxSvgBytes; body above maxRequestBodyBytes
415–Content-Type not supported
422Invalid template · Label generation failed · Unknown field · Font not available · Too many copies · Printer twin check failedtemplate invalid; a value or expression that cannot be evaluated (format, GS1, EPC); a name on the start/end label that is no job value or variable; image file not found; twin block
429Server busy · Job queue fullall render slots busy; job queue full; rate limit (no body)
500Internal errorunexpected – details in the log under the traceId
502Template source unavailable · Query failed · Printer not reachablesource, database, REST service or printer not reachable
503Printer queue full · Job not accepted · Job lostqueueLimit; job status not storable; the instance of a job stopped
504–, Query timeout, Printer did not answerlimits:requestTimeout; query timeout; no ~HS answer

If an error happens after the answer has started (ZPL stream, ZIP), the server aborts the connection – a cut stream never looks complete. Compare X-Record-Count or the SHA-256 of the JSON report when you need certainty.

06Template sources

  • Formats: label JSON (Label.ToJson: one label per record), print job JSON (PrintJobTemplate: record label plus start and end label, job name, stored format, key and copies field) and zplCore Designer documents (their label; their records are the sample data of the preview). The kind is detected from the JSON. Templates are read strictly: an unknown property is an error, older schema versions are migrated.
  • Path: the file path below the source without .json; letters, digits, ., _, - and /. Folders starting with . are skipped (Kubernetes ConfigMaps keep their data in ..data).
  • Priority: with several sources the first one in the list that has the path wins; name:path asks one source.
  • Image files a template names are read only from the template's own volume folder; templates from remote sources read no local image files. Fonts come from the template, its folder (or fonts/) or output:fontFolders (Fonts).

Versions, cache and reloading

  • Every loaded file gets a version: the first 12 hex digits of its SHA-256 (X-Template-Version). The last templates:keepVersions (10) versions stay in memory and are callable as path@version or ?version=.
  • Sources with their own versions are also asked by their id: S3 VersionId, Azure Blob version id, GCS generation, HTTP ETag, the git commit of a git-sync checkout (X-Template-Source-Version). The content hash of a version that is no longer kept cannot be fetched (404).
  • Templates are cached in memory; a job never sees a half-loaded template. Volumes are watched and polled (10 s), remote sources polled (30 s) – only changed objects are downloaded. http loads a template on first use.
  • Errors: a broken new file never replaces the running version – the error is listed in GET /v1/templates and in /ready (problems). /ready answers 503 until every source has been read once. A source that cannot be reached when it has to be asked answers 502; the loaded templates stay in use.
  • Object stores (s3, azureblob, gcs) list at most maxObjects (10,000) objects per prefix; above it the round loads nothing and logs a warning – a prefix that points at a whole bucket by mistake does not download it.

Volume and Kubernetes ConfigMap

templates:
  sources:
    - name: local
      type: volume
      path: /templates      # docker run -v ./templates:/templates:ro, or a ConfigMap mounted there
      poll: 00:00:10

Symbolic links are followed only while the real path stays inside the folder; link loops end the scan of that branch.

Git (git-sync)

Git is not built into the server: a git-sync sidecar clones the repository into a shared volume and swaps a link to every new commit; the server reads it as a volume source and reports the commit as source version (?version=<commit>). The file watcher does not follow the link swap – use a short poll.

# docker-compose.yml
services:
  git-sync:
    image: registry.k8s.io/git-sync/git-sync:v4.7.1
    environment:
      GITSYNC_REPO: https://github.com/example/label-templates.git
      GITSYNC_REF: main
      GITSYNC_ROOT: /git
      GITSYNC_LINK: templates
      GITSYNC_PERIOD: 30s
      GITSYNC_DEPTH: "1"
      GITSYNC_MAX_FAILURES: "-1"     # keep the last checkout when the remote cannot be reached
    volumes: [ "templates:/git" ]
  zplcore-server:
    image: zplcore/server:1.0.0
    depends_on: [ git-sync ]
    ports: [ "8080:8080" ]
    environment:
      ZPLCORE__AUTH__MODE: apikey
      ZPLCORE__AUTH__APIKEYSFILE: /run/secrets/zplcore-api-keys
      ZPLCORE__TEMPLATES__SOURCES__0__NAME: git
      ZPLCORE__TEMPLATES__SOURCES__0__TYPE: volume
      ZPLCORE__TEMPLATES__SOURCES__0__PATH: /git/templates     # or /git/templates/<folder>
      ZPLCORE__TEMPLATES__SOURCES__0__POLL: "00:00:05"
    volumes: [ "templates:/git:ro" ]
    secrets: [ zplcore-api-keys ]
volumes:
  templates:
secrets:
  zplcore-api-keys:
    file: ./api-keys.txt

Private repositories: GITSYNC_USERNAME + GITSYNC_PASSWORD_FILE (token) or GITSYNC_SSH_KEY_FILE (deploy key) on the git-sync container. On Kubernetes run git-sync additionally as init container with --one-time, so the first checkout is there before the server starts (example under Kubernetes).

S3 and MinIO

    - name: shared
      type: s3
      bucket: labels
      prefix: prod/
      region: eu-central-1
      # MinIO / S3-compatible:
      # serviceUrl: http://minio:9000
      # pathStyle: true
      # accessKeyIdFile: /run/secrets/s3-access-key-id
      # secretAccessKeyFile: /run/secrets/s3-secret-access-key   # without keys: AWS default chain
      poll: 00:00:30

Permission: read the bucket below the prefix (list and get). With bucket versioning, older versions are callable by their VersionId.

Azure Blob Storage

    - name: azure
      type: azureblob
      container: labels
      prefix: prod/
      serviceUrl: https://<account>.blob.core.windows.net
      identity: workload        # AKS Workload Identity; or managed (VM, AKS node, App Service, Container Apps)
      # clientId: …             # user-assigned identity
      # connectionStringFile: /run/secrets/azure-storage   # instead: key or SAS
      # sasTokenFile: /run/secrets/azure-sas

Role of the identity: Storage Blob Data Reader for sources, Storage Blob Data Contributor for sinks. The server fetches the token itself (no extra component); with identity, serviceUrl must be https (http only to a local emulator).

Google Cloud Storage

    - name: gcp
      type: gcs
      bucket: labels
      prefix: prod/
      credentialsFile: /run/secrets/gcs-key.json   # without it: GOOGLE_APPLICATION_CREDENTIALS, then the metadata server (Workload Identity, Cloud Run)

The server reads with a read-only scope; the generation of an object is its source version.

HTTP

    - name: remote
      type: http
      baseUrl: https://templates.intern.example/labels/   # shipping/x → …/labels/shipping/x.json
      allowedHosts: [templates.intern.example]
      allowedNetworks: [10.0.5.0/24]                     # on-premises server in a private network
      bearerTokenFile: /run/secrets/templates-token
      poll: 00:01:00

A template is loaded on first use and then polled with If-None-Match/If-Modified-Since; 404/410 removes it. SSRF protection: https only (unless allowHttp), no credentials in the URL, hosts only from allowedHosts (redirects included, at most 3, never from https to http), and every resolved address is checked before connecting – private, loopback and link-local addresses only through allowedNetworks (Security).

07Data sources

Records in the request

Content-TypeRecords
application/jsonone object, an array, or { "records": [ … ], "variables": { … } }; the number of records is known in advance – totals on a start label, X-Record-Count, stored formats from output:storedFormatMinRecords
application/x-ndjsonone JSON object per line, read as a stream (the number is not known in advance)
text/csvheader row = field names, RFC 4180 (quotes, line breaks in quoted fields), read as a stream; separator detected or ?delimiter=

Fields bind by name as in the SDK (data binding): nested paths (Address.City), arrays, expressions. variables are values for the expressions of all labels of the job, e.g. a tour on the start label. Every name the start or end label uses must be a job value (JobName, Created, RecordCount, LabelCount, FirstKey, LastKey, PAGE_NUMBER, TOTAL_PAGES …) or a variable – otherwise 422 “Unknown field”. A missing field on the record label stays empty.

{ "records": [ { "Name": "Anna Huber", "Parcels": 1 }, { "Name": "Max Muster", "Parcels": 2 } ],
  "variables": { "TOUR": "W17", "DEPOT": "Wien" } }

SQL databases

Named queries let the server fetch the records itself: defined in zplcore.yaml, parameterised and read-only – there is no ad-hoc SQL and no free URL. A request calls them by name:

curl -X POST 'localhost:8080/v1/labels/shipping/label-100x150?query=open-shipments&country=AT' -H 'X-Api-Key: <key>'
# or in the body (with job variables):
curl -X POST localhost:8080/v1/labels/shipping/label-100x150 -H 'X-Api-Key: <key>' -H 'Content-Type: application/json' \
  -d '{"query":{"name":"open-shipments","parameters":{"country":"AT"}},"variables":{"TOUR":"W17"}}'
ProviderRead-only byNotes
postgresqlREPEATABLE READ, READ ONLY transactionbuilt-in scalar types (text, numbers, bool, date/time, uuid, bytea); cast others in the SQL (col::text)
mysql (MariaDB)START TRANSACTION READ ONLYAllowLoadLocalInfile and AllowUserVariables are refused
sqliteread-only mode and query_onlyfile from a volume
sqlserver (Azure SQL)transaction that is always rolled back – the login must have read rights only (db_datareader); a warning at start-up reminds of itNativeAOT image: SQL logins only; Entra ID / managed identity, integrated security and Always Encrypted need -full
oracleSET TRANSACTION READ ONLYonly -full with your own driver (below); parameters :name
  • Checked at start-up: exactly one SELECT (or WITH … SELECT) statement, no writing or executing keywords; every placeholder must be a declared parameter and every parameter must be used. Parameters are always bound, never inserted as text. Nothing is ever committed.
  • Parameters come from the query string or parameters of the body, are converted to their type and checked against required and pattern before the query runs (400). The names of the label endpoints' own parameters (template, dpi, copies, print …) are not allowed.
  • countSql makes the number of records known before the first label: start label totals, X-Record-Count, stored formats, and maxRows is checked before anything is generated.
  • Limits: maxRows, timeout (30 s, 504), maxBytes – more rows end the request with an error, never a shortened job. The first row is read before the answer starts, so connection and SQL errors are clean problem answers (502).
  • Column types stay: rows bind as dictionaries, so a date column formats with Format([ShipDate], "dd.MM.yyyy"). Column names must be unique and not empty (use aliases).
  • required: true: /ready answers 503 while the connection is unreachable (checked with a short timeout and cached briefly; no connection details in the answer).
  • GET /v1/queries lists the queries; asynchronous jobs and printing use ?query= the same way.

Oracle

The Oracle driver is not part of any image. Build your own image from zplcore/server:<version>-full with Oracle.ManagedDataAccess.Core and its dependencies in /app/drivers/oracle; then provider: oracle works as the other providers. Without the driver a connection with provider: oracle stops the start with a hint. Oracle's licence terms for the driver apply between you and Oracle.

# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS oracle
WORKDIR /driver
RUN dotnet new classlib -n OracleDriver -o . -f net10.0 \
 && dotnet add package Oracle.ManagedDataAccess.Core \
 && dotnet publish -c Release -r linux-x64 --self-contained false -o /out   # linux-arm64 for ARM
FROM zplcore/server:1.0.0-full
COPY --from=oracle /out/ /app/drivers/oracle/

Not tested by us as a Docker build.

REST services

data:
  connections:
    erp:
      provider: rest
      baseUrl: https://erp.example.com/api/
      allowedNetworks: [10.20.0.0/16]          # on-premises service in a private network
      auth: { type: bearer, tokenFile: /run/secrets/erp-token }
  queries:
    tour-orders:
      connection: erp
      path: orders?tour={tour}                 # {tour}: URL-encoded parameter
      items: $.items                           # JSON path to the array
      paging: { next: link }                   # Link header rel="next", or a JSON path like $.next
      parameters:
        tour: { required: true, pattern: "[A-Z0-9]{1,8}" }

The REST source uses the same SSRF protection as the HTTP template source: https only (unless allowHttp), paging links and redirects only to baseUrl's host and allowedHosts, private addresses only with allowedNetworks; parameters cannot leave the base address. auth:type bearer, basic or header with secrets from files or the environment. Records are JSON; paging:maxPages (100), timeout and maxBytes cover all pages.

08Printing

Printers are configured by name; a request names a printer, never an address – the server is no open TCP relay. Labels are sent over raw TCP (port 9100) and generated at the printer's dpi.

printers:
  lager-1:
    host: 10.0.5.21
    port: 9100
    model: ZD421          # with dpi: printer twin profile ZD421d-203
    dpi: 203
    options: [cutter]
    twin: block
    checkStatus: true
printing:
  twinSampleLabels: 20
  maxCopies: 1000
  • One queue per printer: jobs are sent one after the other (queueLimit, 100 waiting; more: 503).
  • No double print: a connection that fails is retried (retries, retryDelay doubled) only before the first byte. If the connection breaks while sending – or the printer stops reading for longer than sendTimeout (paper out, buffer full) – the job fails and reports the bytes sent; it is not repeated.
  • Printer twin: before sending, the printer twin checks the first printing:twinSampleLabels labels against the profile (model, dpi, options). warn lists the findings in the job status, block stops the job on errors (422, nothing sent), off skips the check; ?twin= overrides it per request.
  • checkStatus: ~HS before every job; a printer that is not ready (paper or ribbon out, head open, paused …) counts as not reachable: nothing is sent, the attempt is repeated.
  • Copies: ?copies=n prints every label n times (^PQ), at most printing:maxCopies; a copies field of a print job template is multiplied.
  • Status: GET /v1/printers/{name}/status asks the printer live; GET /v1/printers shows the queues; metrics per printer (Metrics).
# template + records
curl -X POST 'localhost:8080/v1/print/lager-1?template=shipping/label-100x150' -H 'X-Api-Key: <key>' \
  -H 'Content-Type: application/json' -d '[{"Name":"Anna Huber"}]'
# from the label endpoint, with storage and webhook
curl -X POST 'localhost:8080/v1/labels/shipping/label-100x150?print=lager-1&store=out&webhook=erp' …
# ZPL as it is
curl -X POST localhost:8080/v1/print/lager-1 -H 'X-Api-Key: <key>' -H 'Content-Type: text/plain' --data-binary @label.zpl

Fonts: graphic or printer memory

The server uses no system fonts – neither for the ZPL nor for previews, so preview and print are the same on every host. A TrueType/OpenType font that a text or table item names (anything but the printer's one-character fonts such as 0 or A) must be embedded in the template (the label's fonts), lie as a file in the template's own folder or its fonts/ subfolder, or in a folder of output:fontFolders – matched without regard to case, .TTF/.OTF added when the name has none. Otherwise the request answers 422 “Font not available” with the font and item name; a font is never replaced silently.

fontsWhat the printer getsFor
graphic (default)text in TrueType fonts as a graphic (^GF), exactly as in the preview; no font downloadevery ZPL printer – also printers of other manufacturers with ZPL emulation
printerthe font is downloaded into the printer memory with the first label (~DY) and the printer draws the text (^A@)printers that accept TrueType downloads – for maximum performance

Set it for all requests (output:fonts), per printer (printers:<name>:fonts) or per request (?fonts=graphic|printer, "fonts" in a job document); the request wins over the printer, the printer over output:fonts. ZPL sent as it is (/v1/print without template, "zpl" jobs) is not changed.

The printer memory is optional – only for maximum performance and only on printers that support TrueType downloads (~DY). Many printers with ZPL emulation cannot do that (yet). A font file is also large (a full TrueType font can be several megabytes per job). When in doubt, print as a graphic. In the SDK the same choice is PrintAsGraphic and Label.DownloadFonts.

09Storage, jobs, webhooks, batches

?store=, ?webhook=, ?print= and ?async=true (or Prefer: respond-async) work on POST /v1/labels/…, POST /v1/print/… and POST /v1/batch. Without async the request waits for the job and answers with its status (200, or a problem whose instance is the status URL); a client that disconnects before sending starts cancels the job, a print that has started is completed. With async the answer is 202 with Location: /v1/jobs/{id}.

Storage

sinks:
  out:
    type: volume
    path: /out
    pattern: "{date}/{jobId}.{ext}"     # {date}, {time}, {jobId}, {template}, {ext}
  archive:
    type: s3
    bucket: labels-out
    prefix: zpl/
    region: eu-central-1
  • volume, s3, azureblob, gcs – cloud sinks take the settings and sign-in of the template source of the same type.
  • Every output gets its SHA-256 next to it as <name>.sha256 in the sha256sum format (sha256sum -c checks it); in the cloud also as metadata sha256.
  • Readers never see a half-written object: volumes write to a temporary file and rename it, cloud sinks write in one request.
  • The job status names the sink, key and location (file:///out/…, s3://bucket/…).
$ cat out/2026-10-25/b4b21ff0c9ab4189b3b19960eec4b9d2.zpl.sha256
9abb10f7b11b90aa349f982003246b3145e4409bad92e8c10c6488016c5df3fa  b4b21ff0c9ab4189b3b19960eec4b9d2.zpl

Asynchronous jobs

Mode (/ready)ConfigurationBehaviour
memoryno jobs:sinkstatus and results in memory and the local spool folder – one instance only; a restart loses them (warning at start-up)
storagejobs:sinkstatus jobs/{id}/status.json and result jobs/{id}/result.{ext} in the sink – every instance can answer for every job
storage+redisjobs:sink + jobs:redisthe status additionally in Redis for fast polling; the sink stays the authority (a status Redis missed during an outage is repaired)
  • Every instance runs at most jobs:workers jobs at once and queues jobs:queueCapacity more (429 beyond). A job waits for a free render slot instead of failing.
  • The request body is copied into the spool folder (jobs:spoolPath, default the temp folder – with a read-only root file system mount a writable /tmp).
  • Progress is updated every 2 s, a sign of life every 30 s; a waiting or running job without a sign of life for 2 minutes counts as lost (failed, 503 “Job lost”) – submit it again.
  • DELETE /v1/jobs/{id} cancels a job, also one running on another instance (through a cancel mark in the sink). On shutdown, jobs that are sending to a printer may finish within the shutdown timeout.
  • Status and result expire after jobs:ttl (24 h); a periodic cleanup deletes them (Redis keys expire by themselves). A lifecycle rule in the bucket is a good addition.

Webhooks

When a job ends, the server POSTs a JSON document to the named receiver: event job.succeeded, job.failed or job.canceled, job id, status, template and version, format, counts, bytes, SHA-256, storage location, result URL, print result, error and times.

{ "event": "job.succeeded", "jobId": "6fb805d73e1e40f8aff8b6290fc62360", "status": "succeeded",
  "template": "shipping/label-100x150", "templateVersion": "35ad7214ed79", "format": "pdf",
  "records": 2, "labels": 2, "bytes": 188985, "sha256": "cb1e2c80…",
  "location": "s3://labels-out/zpl/2026-10-25/6fb805d73e1e40f8aff8b6290fc62360.pdf",
  "result": "/v1/jobs/6fb805d73e1e40f8aff8b6290fc62360/result",
  "created": "2026-10-25T08:18:20Z", "finished": "2026-10-25T08:18:21Z" }
HeaderContent
X-ZplCore-Eventjob.succeeded …
X-ZplCore-Deliveryid of the delivery – the same for every attempt (deduplicate with it)
X-ZplCore-TimestampUnix seconds of the attempt
X-ZplCore-Signaturesha256=<hex> – HMAC-SHA256 of "<timestamp>.<body>" with the webhook's key
  • Retries on network errors, timeouts, 408, 429 and 5xx with doubled waits plus jitter; Retry-After is honoured (at most 5 minutes). Other answers and redirects end the delivery. The result is in the job status (webhook.state pending/delivered/failed, attempts, last status).
  • Outbox: with jobs:sink the deliveries are kept in the sink (webhooks/{id}/…) until they end – retries survive a restart, and exactly one instance makes each attempt. Without a sink they are kept in memory.
  • Targets only from the configuration – no URLs from requests; https only (unless allowHttp), private networks only with allowPrivateNetworks or allowedNetworks.

Checking the signature on the receiver – over the raw body bytes, before parsing, in fixed time, with a time window against replays:

// C# (.NET)
using System.Security.Cryptography;
using System.Text;

static bool IsValid(byte[] body, string? timestamp, string? signature, string secret, DateTimeOffset now)
{
    if (!long.TryParse(timestamp, out long ts) || Math.Abs(now.ToUnixTimeSeconds() - ts) > 300) return false; // replay
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    byte[] hash = hmac.ComputeHash([.. Encoding.ASCII.GetBytes(timestamp + "."), .. body]);
    string expected = "sha256=" + Convert.ToHexStringLower(hash);
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature ?? ""));
}
// Node.js – no packages
const crypto = require("node:crypto");
const http = require("node:http");

function isValid(body, timestamp, signature, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > 300) return false; // replay
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(timestamp + ".").update(body).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signature ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", c => chunks.push(c)).on("end", () => {
    const body = Buffer.concat(chunks); // the raw bytes, never re-serialised JSON
    const ok = isValid(body, req.headers["x-zplcore-timestamp"], req.headers["x-zplcore-signature"], process.env.WEBHOOK_SECRET);
    res.writeHead(ok ? 204 : 401).end();
    if (ok) console.log(JSON.parse(body).event, JSON.parse(body).jobId);
  });
}).listen(8090);
# Python 3 – standard library only
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["WEBHOOK_SECRET"].encode()

def is_valid(body: bytes, timestamp: str, signature: str) -> bool:
    if not timestamp or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:  # replay
        return False
    expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers["Content-Length"]))  # the raw bytes
        ok = is_valid(body, self.headers["X-ZplCore-Timestamp"], self.headers["X-ZplCore-Signature"])
        self.send_response(204 if ok else 401)
        self.end_headers()
        if ok:
            print(json.loads(body)["event"], flush=True)

HTTPServer(("", 8090), Hook).serve_forever()

Batches

POST /v1/batch generates several templates with their own records in one request – e.g. the shipping labels of all carriers of a tour. Every part is a job of its own (start and end label, counters, stored formats). JSON bodies are checked completely before the first byte; NDJSON is read part by part, an error in the middle aborts the connection. With store, webhook, print or async the batch runs as one job (ZIP when printing: 406 – printing sends ZPL). Details under Endpoints.

# NDJSON: one part per line
printf '%s\n' '{"template":"shipping/label-100x150","records":[{"Name":"A"}]}' \
              '{"template":"shipping/label-100x150","records":[{"Name":"B"}]}' > parts.ndjson
curl -X POST localhost:8080/v1/batch -H 'X-Api-Key: <key>' -H 'Content-Type: application/x-ndjson' \
  --data-binary @parts.ndjson -o batch.zpl

10Security

  • Authentication required: no default mode; jwt only with an audience; API keys at least 16 characters, compared as hashes in fixed time (Authentication).
  • Limits per request: body size, records, labels of image outputs, template size, label side, field length, render slots with queue and wait time, request timeout and an optional rate limit (limits). A client that disconnects or the request timeout stops generation and rendering; the render slot becomes free.
  • Images: every job runs inside a ZplLimits scope. Image files (sourceFile, or a path in a record) are read only below the folder of the template's own volume source; remote and inline templates read no local files. Images are checked against maxImagePixels, maxImageBytes and maxSvgBytes before they are decoded (413).
  • Outbound calls (HTTP templates, REST queries, webhooks) go only to configured targets – never to an address from a request. Every resolved address is checked before connecting and exactly that address is used (no DNS rebinding). Blocked unless allowed: loopback, private networks (RFC 1918, carrier-grade NAT, IPv6 unique local), link-local with the cloud metadata address 169.254.169.254, the Azure platform address 168.63.129.16, reserved, benchmarking and documentation ranges; IPv6 forms that carry an IPv4 address are judged by that address. Unspecified and multicast addresses are never allowed. allowedNetworks (CIDR) opens single networks, allowPrivateNetworks (webhooks) private networks and loopback. No proxy, no credentials in URLs, redirects only to allowed hosts and never from https to http.
  • Data sources are read-only (see SQL); no ad-hoc SQL, no free URLs.
  • Printers only by name, no TCP relay.
  • Secrets only from files or the environment; never in logs, answers or catalogues (Secrets). Errors of the engine reach the client with neutral texts; 500 answers carry only a traceId.
  • Anonymous probes reveal nothing: /ready without authentication answers only the status; /metrics is off by default.
  • Responses: X-Content-Type-Options: nosniff on every answer, SVG with Content-Security-Policy: sandbox; request logs without the query string.
  • Container: non-root (UID 1654), no shell, /templates read-only; run it with a read-only root file system and without capabilities:
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
  -v ./templates:/templates:ro -v ./zplcore.yaml:/config/zplcore.yaml:ro \
  -e ZPLCORE__AUTH__MODE=apikey -e ZPLCORE__AUTH__APIKEYS__0=<key> \
  zplcore/server:1.0.0

11Operations

  • /health (liveness) answers ok as long as the process runs.
  • /ready (readiness) answers 200 once the renderer is loaded, every template source has been read once and every data connection with required: true answers; otherwise 503. Evaluation mode is ready too – it only adds the watermark. Authenticated callers get the details (example): version, build date, licence state, templates, problems (source errors, licence reload), jobs, printers, sinks and webhooks.

Metrics and logs

/metrics is off by default. metrics:enabled: true plus either metrics:port (an own port such as 9464, answered only there and without further check – keep the port inside the cluster) or on the main port the metrics key ZPLCORE__METRICS__APIKEY in the API key header, or a request that passes the authentication.

MetricTypeLabels
zplcore_server_requests_totalcounterformat, status
zplcore_server_labels_total, zplcore_server_output_bytes_totalcounterformat
zplcore_server_request_duration_secondshistogramformat
zplcore_server_rejected_totalcounterreason
zplcore_server_renders_busy, _renders_queued, _renders_capacitygauge
zplcore_server_templates, zplcore_server_template_errorsgauge
zplcore_server_license_watermarkedgauge1 = evaluation mode
zplcore_server_print_jobs_totalcounterprinter, result
zplcore_server_print_retries_totalcounterprinter
zplcore_server_print_queue, zplcore_server_print_busygaugeprinter
zplcore_server_jobs_totalcounterstate
zplcore_server_jobs_queued, zplcore_server_jobs_runninggauge
zplcore_server_stored_totalcountersink
zplcore_server_webhook_deliveries_totalcounterwebhook, result

Plus process and runtime values (process_working_set_bytes, process_uptime_seconds, dotnet_gc_heap_bytes …).

  • OpenTelemetry: with OTEL_EXPORTER_OTLP_ENDPOINT the server exports metrics (meter ZplCore.Server and the ASP.NET Core/runtime meters), traces (without query strings, without probes) and logs over OTLP; the image sets OTEL_SERVICE_NAME=zplcore-server.
  • Logs: JSON lines on standard output, timestamps in UTC; one line per request with method, path, status and duration – never the query string (it may carry parameters). The start line names the version, start time, number of templates, render slots and licence state.

Kubernetes and scaling

The server keeps no state of its own: several replicas behind a load balancer need no sticky sessions. For asynchronous jobs across replicas configure jobs:sink (and optionally Redis); without it every replica only knows its own jobs. Scale on CPU or on the metrics zplcore_server_renders_queued and zplcore_server_jobs_queued.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: zplcore-server
spec:
  replicas: 2
  selector: { matchLabels: { app: zplcore-server } }
  template:
    metadata: { labels: { app: zplcore-server } }
    spec:
      securityContext: { runAsNonRoot: true, seccompProfile: { type: RuntimeDefault } }
      containers:
        - name: zplcore-server
          image: zplcore/server:1.0.0
          ports: [ { name: http, containerPort: 8080 } ]
          env:
            - { name: ZPLCORE__AUTH__MODE, value: apikey }
            - { name: ZPLCORE__AUTH__APIKEYSFILE, value: /run/secrets/zplcore/api-keys }
          volumeMounts:
            - { name: templates, mountPath: /templates, readOnly: true }
            - { name: api-keys, mountPath: /run/secrets/zplcore, readOnly: true }
            - { name: license, mountPath: /run/secrets/zplcore-license, subPath: zplcore-license, readOnly: true }
            - { name: tmp, mountPath: /tmp }
          readinessProbe: { httpGet: { path: /ready, port: http } }
          livenessProbe: { httpGet: { path: /health, port: http } }
          resources: { requests: { cpu: 250m, memory: 256Mi }, limits: { memory: 1Gi } }
          securityContext:
            runAsUser: 1654
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: [ "ALL" ] }
      volumes:
        - { name: templates, configMap: { name: label-templates } }
        - { name: api-keys, secret: { secretName: zplcore-api-keys } }
        - { name: license, secret: { secretName: zplcore-license } }
        - { name: tmp, emptyDir: {} }

A Helm chart for zplCore Server is planned. Templates from Git: git-sync as init container (--one-time) and sidecar sharing an emptyDir volume with the server (Git).

NativeAOT, -full and platforms

NativeAOT (default image)-full (JIT)
Start, sizeone native binary, starts fast, smaller image.NET runtime, slower start
Oracle–with your own driver
SQL ServerSQL loginsalso Entra ID / managed identity, integrated security, Always Encrypted
ZPL generation of records in memoryon all cores (output:generationThreads 0)serial (byte-identical output)
Platformslinux/amd64, linux/arm64linux/amd64, linux/arm64; linux/arm/v7 as -arm32v7

Both variants produce the same output. The images are based on Ubuntu (chiseled, glibc) and contain the native libraries the renderer needs. 32-bit ARM, e.g. Raspberry Pi with a 32-bit OS, needs the -arm32v7 image; a 64-bit Raspberry Pi OS uses the default image (linux/arm64). Windows and macOS run the Linux images with Docker Desktop.

12Troubleshooting

The container stops at once (exit code 2)

Configuration errors end the start with exit code 2 and one line on standard error (no stack trace):

Message (shortened)Fix
auth:mode is not set. Set the environment variable ZPLCORE__AUTH__MODE …-e ZPLCORE__AUTH__MODE=apikey (with a key), jwt or – locally – none
auth:mode 'apikey' needs at least one key … / API keys must be at least 16 characters long.ZPLCORE__AUTH__APIKEYS__0 or auth:apiKeysFile with a long enough key
auth:mode 'jwt' needs auth:audience (ZPLCORE__AUTH__AUDIENCE) …set the audience your identity provider issues the tokens for
…zplcore.yaml: secrets must not be stored in the configuration file (license:key). Use a secret file … or environment variables …move the value into a …File key or a ZPLCORE__… variable
Configuration file '…' does not exist.check --config, ZPLCORE_CONFIG and the mount
data:connections:<name>:provider 'oracle' needs the Oracle driver …build the -full image with the driver (Oracle)
a message naming another key (limits:…, printers:<name>:…, sinks:…, an unknown twin profile, a SQL statement that is not read-only …)the message names the key and the allowed values

Typical answers

  • 401 – header missing or wrong: X-Api-Key (or auth:apiKeyHeader), or the JWT has the wrong audience, issuer or role.
  • 406 – Accept names an unsupported type, or PNG/SVG for several labels (use application/zip or application/pdf).
  • 413 – image outputs above limits:maxRenderLabels (use ZPL for large jobs), an image above the image limits, or a body above limits:maxRequestBodyBytes.
  • 422 – read title and detail: “Unknown field” (send the name as a job variable), “Font not available”, “Label generation failed” (a value or expression – the log has the details under the traceId), “Invalid template”, “Printer twin check failed”.
  • 429 – all render slots busy: raise limits:maxConcurrentRenders/maxQueuedRenders or add replicas; without a body: the rate limit.
  • 502 – a template source, database, REST service or printer cannot be reached; the detail names which one (never credentials).
  • 504 – limits:requestTimeout, a query timeout, or a printer that does not answer ~HS.

Templates are not loaded

  • GET /v1/templates lists files that could not be loaded with the reason (errors), and every source with its last error; /ready (authenticated) shows problems.
  • Only *.json files with a valid label, print job or Designer document; unknown properties are errors (e.g. a typo or a property of a newer schema).
  • The volume must be readable by UID 1654; links must not leave the folder; folders starting with . are skipped.
  • Object stores: is prefix right, and are there more than maxObjects objects below it? git-sync: is poll short and the path /git/templates?

“Font not available”

The server never uses system fonts. Embed the font in the template, put the file next to the template (or into its fonts/ folder), or mount a folder and list it in output:fontFolders. The name in the template (E:ARIAL.TTF) is matched without the drive and without regard to case.

The printer does not print

  • GET /v1/printers/{name}/status: is the printer reachable and ready (paper, ribbon, head, pause)?
  • The job status (print.state, print.error, print.twin.findings): blocked = the printer twin found errors with twin: block; failed after bytes were sent = the connection broke, nothing was repeated.
  • Text in TrueType fonts missing or wrong on a printer with ZPL emulation: use fonts: graphic (Fonts).
  • Wrong size or position: dpi and model of the printer in zplcore.yaml must match the device.