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.
01Overview
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
- 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) | |
|---|---|---|
| Runs | in your .NET application | as its own service, called over HTTP from any language |
| Templates | loaded by your code | on the server: volume, Git, S3, Azure Blob, GCS, HTTP – reloaded and versioned |
| Data | your objects, EF Core, LiteDB … | JSON, NDJSON, CSV or named SQL/REST queries – no code |
| Printing | your code (TCP, USB, spooler, CUPS) | configured network printers (TCP 9100) with queue and printer twin |
| Suits | .NET teams who want full control | teams without .NET, central label services, Kubernetes |
02Quick start
Images and tags
| Tag (planned) | Build | Platforms | When |
|---|---|---|---|
zplcore/server:1.0.0 | NativeAOT – one binary, starts fast, small | linux/amd64, linux/arm64 | the default |
zplcore/server:1.0.0-full | JIT (.NET runtime) | linux/amd64, linux/arm64 | your own Oracle driver; SQL Server with Entra ID, integrated security or Always Encrypted |
zplcore/server:1.0.0-arm32v7 | JIT | linux/arm/v7 | 32-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:
ZPLCORE__LICENSE__KEY– the key itself as an environment variable,- 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, - the SDK's own sources:
ZPLCORE_LICENSE,ZPLCORE_LICENSE_FILE, a filezplcore.licensenext 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:
--config <file>on the command line,- the environment variable
ZPLCORE_CONFIG, /config/zplcore.yaml(mount your own there),- the
zplcore.yamlnext 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
| Key | Default | Meaning |
|---|---|---|
mode | required | apikey, jwt (also oidc) or none |
apiKeyHeader | X-Api-Key | header that carries the API key |
apiKeys | env only: ZPLCORE__AUTH__APIKEYS__0, __1 …; at least 16 characters each | |
apiKeysFile | file with one key per line, # starts a comment | |
authority | OIDC issuer (mode jwt, required) | |
audience | expected audience of the token (mode jwt, required) | |
requiredRole | role the token must carry (claim roles or role) | |
requireHttpsMetadata | true | fetch the OIDC metadata over https only |
license
| Key | Default | Meaning |
|---|---|---|
file | /run/secrets/zplcore-license | file with the licence key, checked every 30 s |
key | env only: ZPLCORE__LICENSE__KEY |
templates
| Key | Default | Meaning |
|---|---|---|
sources | image: volume /templates | list of template sources in priority order (keys below) |
keepVersions | 10 | earlier versions kept per template (callable as path@version) |
inline:enabled | false | enables POST /v1/render (template in the request) |
Keys of one source (templates:sources:N):
| Key | Default | Types | Meaning |
|---|---|---|---|
name | all | name of the source (letters, digits, ., _, -); name:path asks this source | |
type | volume | all | volume, s3, azureblob, gcs, http |
poll | volume 10 s, others 30 s | all | polling interval |
timeout | 00:00:30 | remote | longest time of one request to the source |
path | volume | folder, mounted read-only | |
bucket / container | s3, gcs / azureblob | bucket or container | |
prefix | s3, azureblob, gcs | key prefix, e.g. prod/: prod/shipping/x.json → shipping/x | |
serviceUrl | the cloud service | s3, azureblob, gcs | S3-compatible endpoint (MinIO), Azure account URL, other GCS endpoint |
maxObjects | 10000 | s3, azureblob, gcs | most objects one listing may return (0 = no limit) |
region | AWS_REGION | s3 | AWS region; with serviceUrl the signing region (default us-east-1) |
pathStyle | false | s3 | path-style requests (MinIO and most S3-compatible stores) |
accessKeyId / accessKeyIdFile | s3 | access key id; without keys the AWS default chain | |
secretAccessKey / secretAccessKeyFile | s3 | secret access key (value env only) | |
sessionToken / sessionTokenFile | s3 | session token of temporary credentials (value env only) | |
connectionString / connectionStringFile | azureblob | storage connection string (value env only) | |
sasToken / sasTokenFile | azureblob | SAS token for serviceUrl (value env only) | |
identity | azureblob | managed (managed identity) or workload (AKS Workload Identity), with serviceUrl | |
clientId / tenantId | AZURE_CLIENT_ID / AZURE_TENANT_ID | azureblob | user-assigned identity or workload identity |
credentialsFile | GOOGLE_APPLICATION_CREDENTIALS | gcs | service account key (JSON); without it the metadata server |
anonymous | false | gcs | no authentication (emulator, public bucket) |
baseUrl | http | base URL; shipping/x is <baseUrl>shipping/x.json | |
allowedHosts | host of baseUrl | http | hosts the source may request, redirects included |
allowedNetworks | http | networks (CIDR) that may be reached although private, loopback or link-local | |
allowHttp | false | http | allow plain http |
bearerToken / bearerTokenFile | http | bearer token (value env only) | |
basicAuth / basicAuthFile | http | user:password (value env only) |
limits
| Key | Default | Meaning |
|---|---|---|
maxRequestBodyBytes | 268435456 (256 MiB) | largest request body (413) |
maxRecordsPerRequest | 1000000 | records per request (also all parts of a batch together) |
maxRenderLabels | 1000 | labels per request for PNG, SVG, PDF, ZIP and JSON with diagnostics (413) |
maxTemplateBytes | 8388608 (8 MiB) | largest template file |
maxConcurrentRenders | 0 = processors | render slots |
maxQueuedRenders | 64 | requests waiting for a slot; more: 429 |
queueTimeout | 00:00:30 | longest wait for a slot; then 429 |
requestTimeout | 00:10:00 | longest time of one request (504) |
rateLimitPermits | 0 = off | requests per window and client |
rateLimitWindow | 00:01:00 | window of the rate limit |
maxLabelSide | 8000 | largest label side in dots (also ^PW/^LL) |
maxFieldDataLength | 65536 | characters of one field |
maxImagePixels | 40000000 | largest image (pixels) an image, QR logo or barcode art decodes; 0 = no limit |
maxImageBytes | 10485760 (10 MiB) | largest raster image of a template or record; 0 = no limit |
maxSvgBytes | 2097152 (2 MiB) | largest SVG of a template or record; 0 = no limit |
output
| Key | Default | Meaning |
|---|---|---|
defaultDpi | 203 | resolution when the request names none (100–1200) |
storedFormatMinRecords | 1000 | from this many records (count known in advance) the record label is stored once in the printer (^DF/^XF, prints the same); 0 = never |
storedFormatName | R:ZPLCORE.ZPL | name of that stored format |
fontFolders | folders with TTF/OTF files templates may name (Fonts) | |
fonts | graphic | graphic or printer: how text in TrueType fonts reaches the printer (Fonts) |
generationThreads | 0 = automatic | threads that build the ZPL of one request with records in memory: processors in the NativeAOT image, 1 under JIT; 1 = serial |
docs, metrics
| Key | Default | Meaning |
|---|---|---|
docs:enabled | true | /docs (Scalar) and /openapi/v1.json |
metrics:enabled | false | /metrics in the Prometheus format |
metrics:port | own port for /metrics (e.g. 9464), without further check | |
metrics:apiKey | env only: key that opens /metrics on the main port |
data – named queries
data:connections:<name>:
| Key | Default | Meaning |
|---|---|---|
provider | sqlite, postgresql, sqlserver, mysql (also MariaDB), oracle (-full image with your driver) or rest | |
required | false | /ready answers 503 while the connection is unreachable |
connectionString / connectionStringFile | databases (value env only) | |
baseUrl | REST: base address; query paths are relative to it | |
allowedHosts | host of baseUrl | REST: further hosts for paging links and redirects |
allowedNetworks | REST: networks (CIDR) that may be reached although private, loopback or link-local | |
allowHttp | false | REST: allow plain http |
auth:type | none | REST: bearer, basic, header |
auth:token / auth:tokenFile | bearer token (value env only) | |
auth:username, auth:password / auth:passwordFile | basic (password env only) | |
auth:header, auth:value / auth:valueFile | own header, e.g. X-Api-Key (value env only) |
data:queries:<name>:
| Key | Default | Meaning |
|---|---|---|
connection | name of the connection | |
sql | databases: one SELECT (or WITH … SELECT); parameters @name (Oracle :name) | |
countSql | databases, optional: the number of rows of sql (same parameters) | |
path | REST: path and query relative to baseUrl; {name} = URL-encoded parameter | |
items | $ | REST: JSON path to the array of records, e.g. $.items |
paging:next | REST: link (Link header rel="next") or a JSON path like $.next | |
paging:maxPages | 100 | REST: most pages of one call |
parameters:<name>:type | string | string, int, long, decimal, double, bool, date (yyyy-MM-dd), datetime (ISO 8601) |
parameters:<name>:required | false | the parameter must be given |
parameters:<name>:pattern | strings: regular expression for the whole value | |
maxRows | 0 = limits:maxRecordsPerRequest | most rows of one call; more end the request with an error |
timeout | 00:00:30 | longest time of one call (all rows, all pages) |
maxBytes | 0 = limits:maxRequestBodyBytes | most data of one call |
printers, printing
printers:<name>:
| Key | Default | Meaning |
|---|---|---|
host | host name or IP address of the printer | |
port | 9100 | raw TCP port |
model | model, e.g. ZD421; with dpi it selects the printer twin profile | |
profile | from model + dpi, else generic | printer twin profile, e.g. ZD421d-203 |
dpi | profile, else output:defaultDpi | resolution the labels are generated at |
options | installed options for the twin: cutter, peeler, rewinder, rfid, linerless, applicator, realtimeclock | |
twin | warn | off, warn or block |
fonts | output:fonts | graphic or printer for this printer |
timeout | 00:00:10 | connect and ~HS |
sendTimeout | 00:01:00 | longest time without progress while sending |
retries | 2 | further attempts – only before the first byte |
retryDelay | 00:00:01 | wait before the first retry, doubled for each further one |
queueLimit | 100 | jobs waiting for this printer; more: 503 |
checkStatus | false | ask ~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>: | Default | Meaning |
|---|---|---|
url | receiver (https) | |
secretFile / secret | signing key, at least 16 characters (value env only: ZPLCORE__WEBHOOKS__<NAME>__SECRET) | |
timeout | 00:00:10 | per attempt |
retries | 5 | further attempts after network errors, timeouts, 408, 429, 5xx |
retryDelay | 00:00:02 | first wait, doubled each time (at most 5 minutes) |
allowHttp | false | allow plain http |
allowPrivateNetworks | false | allow private networks and loopback |
allowedNetworks | networks (CIDR) allowed although blocked otherwise (also link-local) |
jobs, batch
| Key | Default | Meaning |
|---|---|---|
jobs:enabled | true | asynchronous jobs and /v1/jobs |
jobs:workers | 0 = processors | jobs running at the same time on this instance |
jobs:queueCapacity | 100 | jobs waiting on this instance; more: 429 |
jobs:sink | sink for job status and results (jobs/{id}/…); without it: memory, one instance | |
jobs:ttl | 24:00:00 | how long status and result are kept |
jobs:cleanupInterval | 00:10:00 | how often expired jobs are deleted |
jobs:spoolPath | temp folder | local folder for request bodies and outputs while a job runs |
jobs:redis:connectionString / connectionStringFile | job status additionally in Redis (needs jobs:sink; value env only) | |
jobs:redis:keyPrefix | zplcore:jobs: | prefix of the Redis keys |
batch:maxParts | 1000 | parts 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
| Mode | How | For |
|---|---|---|
apikey | key 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 time | service-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:requiredRole | Entra ID, Keycloak, Auth0 … |
none | no check – everybody who reaches the server may call it; a warning in the log | local 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.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/labels/{template} | fill a template with records, return the labels (or print, store, notify) |
| POST | /v1/render | the same with the template in the request (off by default) |
| GET | /v1/templates | catalogue: templates, versions, errors, sources |
| GET | /v1/templates/{path}, …/preview | one template as JSON; PNG of its first label |
| GET | /v1/queries | named queries with their parameters |
| POST | /v1/print/{printer} | print ZPL, or a template with records |
| GET | /v1/printers, /v1/printers/{name}/status | printers with queue; live status (~HS) |
| POST · GET · DELETE | /v1/jobs, /v1/jobs/{id}, /v1/jobs/{id}/result | asynchronous jobs |
| POST | /v1/batch | several templates and record sets in one request |
| GET | /health, /ready, /metrics, /docs, /openapi/v1.json | operation |
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:
Accept | Answer |
|---|---|
application/vnd.zpl (default; also text/plain, */*) | ZPL, streamed record by record |
image/png, image/svg+xml | exactly one label; several → 406 |
application/pdf | one PDF, one page per label |
application/zip | one image per label (label00001.png …), written while rendering |
application/json | report { template, version, records, labels, zplBytes, sha256, license, diagnostics?, zpl? } |
| Query parameter | Meaning |
|---|---|
dpi | 100–1200, default output:defaultDpi |
version | template version (content hash or the source's version id) |
format | ZIP entries: png (default) or svg |
diagnostics | true: the JSON report renders the labels and lists the renderer's diagnostics |
zpl | true: the JSON report includes the ZPL |
copies | prints every label that often (^PQ), 1 to printing:maxCopies |
fonts | graphic or printer (Fonts) |
delimiter | CSV separator: , ; tab | (default: detected) |
query + its parameters | records from a named query, e.g. ?query=open-shipments&country=AT |
print, twin | send to a configured printer; twin check off/warn/block |
store, webhook | write to a sink; notify a webhook |
async | true (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
| Request | Answer |
|---|---|
POST /v1/jobs | 202, 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}/result | the 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:
| Property | Meaning |
|---|---|
template, version | template path and version |
records or query | records (array or one object), or { "name": …, "parameters": { … } } |
variables | job variables (start and end label) |
zpl | ZPL to print or store as it is (instead of a template) |
parts | a batch: [ { "template", "records", "variables", "version", "copies" }, … ] |
format, zipEntries | zpl (default), png, svg, pdf, zip, json; ZIP entries png/svg (batch zpl/pdf) |
dpi, copies, fonts, diagnostics, includeZpl | as the query parameters of /v1/labels |
print, twin, store, webhook | printer, 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
| Path | Answer |
|---|---|
GET /health | ok – liveness, anonymous |
GET /ready | 200 {"status":"ready"} or 503 {"status":"not-ready"}; with authentication the details below |
GET /metrics | Prometheus text format; off by default (Metrics) |
GET /docs | interactive API reference (Scalar), served by the server itself – no CDN |
GET /openapi/v1.json | OpenAPI 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" }
| Status | Typical title | Cause |
|---|---|---|
| 400 | Invalid records · Invalid dpi · Invalid copies · Invalid query parameters · Local files not allowed | JSON/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 |
| 404 | Template not found · Query not found · Printer not found · Sink not found · Webhook not found · Job not found | unknown name, version or expired job |
| 406 | Unsupported Accept header · More than one label | Accept not supported; PNG/SVG for several labels; ZIP when printing |
| 409 | Job not finished · Job did not succeed | result requested too early or of a failed job |
| 413 | Too many labels · Image too large | limits:maxRenderLabels; an image above maxImagePixels/maxImageBytes/maxSvgBytes; body above maxRequestBodyBytes |
| 415 | – | Content-Type not supported |
| 422 | Invalid template · Label generation failed · Unknown field · Font not available · Too many copies · Printer twin check failed | template 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 |
| 429 | Server busy · Job queue full | all render slots busy; job queue full; rate limit (no body) |
| 500 | Internal error | unexpected – details in the log under the traceId |
| 502 | Template source unavailable · Query failed · Printer not reachable | source, database, REST service or printer not reachable |
| 503 | Printer queue full · Job not accepted · Job lost | queueLimit; job status not storable; the instance of a job stopped |
| 504 | –, Query timeout, Printer did not answer | limits: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:pathasks 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/) oroutput: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 lasttemplates:keepVersions(10) versions stay in memory and are callable aspath@versionor?version=. - Sources with their own versions are also asked by their id: S3
VersionId, Azure Blob version id, GCS generation, HTTPETag, 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.
httploads a template on first use. - Errors: a broken new file never replaces the running version – the error is listed in
GET /v1/templatesand in/ready(problems)./readyanswers 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 mostmaxObjects(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-Type | Records |
|---|---|
application/json | one 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-ndjson | one JSON object per line, read as a stream (the number is not known in advance) |
text/csv | header 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"}}'
| Provider | Read-only by | Notes |
|---|---|---|
postgresql | REPEATABLE READ, READ ONLY transaction | built-in scalar types (text, numbers, bool, date/time, uuid, bytea); cast others in the SQL (col::text) |
mysql (MariaDB) | START TRANSACTION READ ONLY | AllowLoadLocalInfile and AllowUserVariables are refused |
sqlite | read-only mode and query_only | file 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 it | NativeAOT image: SQL logins only; Entra ID / managed identity, integrated security and Always Encrypted need -full |
oracle | SET TRANSACTION READ ONLY | only -full with your own driver (below); parameters :name |
- Checked at start-up: exactly one
SELECT(orWITH … 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
parametersof the body, are converted to theirtypeand checked againstrequiredandpatternbefore the query runs (400). The names of the label endpoints' own parameters (template,dpi,copies,print…) are not allowed. countSqlmakes the number of records known before the first label: start label totals,X-Record-Count, stored formats, andmaxRowsis 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:/readyanswers 503 while the connection is unreachable (checked with a short timeout and cached briefly; no connection details in the answer).GET /v1/querieslists 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,retryDelaydoubled) only before the first byte. If the connection breaks while sending – or the printer stops reading for longer thansendTimeout(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:twinSampleLabelslabels against the profile (model, dpi, options).warnlists the findings in the job status,blockstops the job on errors (422, nothing sent),offskips the check;?twin=overrides it per request. checkStatus:~HSbefore 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=nprints every label n times (^PQ), at mostprinting:maxCopies; a copies field of a print job template is multiplied. - Status:
GET /v1/printers/{name}/statusasks the printer live;GET /v1/printersshows 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.
fonts | What the printer gets | For |
|---|---|---|
graphic (default) | text in TrueType fonts as a graphic (^GF), exactly as in the preview; no font download | every ZPL printer – also printers of other manufacturers with ZPL emulation |
printer | the 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.
~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>.sha256in thesha256sumformat (sha256sum -cchecks it); in the cloud also as metadatasha256. - 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) | Configuration | Behaviour |
|---|---|---|
memory | no jobs:sink | status and results in memory and the local spool folder – one instance only; a restart loses them (warning at start-up) |
storage | jobs:sink | status jobs/{id}/status.json and result jobs/{id}/result.{ext} in the sink – every instance can answer for every job |
storage+redis | jobs:sink + jobs:redis | the 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:workersjobs at once and queuesjobs:queueCapacitymore (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" }
| Header | Content |
|---|---|
X-ZplCore-Event | job.succeeded … |
X-ZplCore-Delivery | id of the delivery – the same for every attempt (deduplicate with it) |
X-ZplCore-Timestamp | Unix seconds of the attempt |
X-ZplCore-Signature | sha256=<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-Afteris honoured (at most 5 minutes). Other answers and redirects end the delivery. The result is in the job status (webhook.statepending/delivered/failed, attempts, last status). - Outbox: with
jobs:sinkthe 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 withallowPrivateNetworksorallowedNetworks.
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;
jwtonly 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
ZplLimitsscope. 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 againstmaxImagePixels,maxImageBytesandmaxSvgBytesbefore 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:
/readywithout authentication answers only the status;/metricsis off by default. - Responses:
X-Content-Type-Options: nosniffon every answer, SVG withContent-Security-Policy: sandbox; request logs without the query string. - Container: non-root (UID 1654), no shell,
/templatesread-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) answersokas 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 withrequired: trueanswers; 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.
| Metric | Type | Labels |
|---|---|---|
zplcore_server_requests_total | counter | format, status |
zplcore_server_labels_total, zplcore_server_output_bytes_total | counter | format |
zplcore_server_request_duration_seconds | histogram | format |
zplcore_server_rejected_total | counter | reason |
zplcore_server_renders_busy, _renders_queued, _renders_capacity | gauge | |
zplcore_server_templates, zplcore_server_template_errors | gauge | |
zplcore_server_license_watermarked | gauge | 1 = evaluation mode |
zplcore_server_print_jobs_total | counter | printer, result |
zplcore_server_print_retries_total | counter | printer |
zplcore_server_print_queue, zplcore_server_print_busy | gauge | printer |
zplcore_server_jobs_total | counter | state |
zplcore_server_jobs_queued, zplcore_server_jobs_running | gauge | |
zplcore_server_stored_total | counter | sink |
zplcore_server_webhook_deliveries_total | counter | webhook, result |
Plus process and runtime values (process_working_set_bytes, process_uptime_seconds, dotnet_gc_heap_bytes …).
- OpenTelemetry: with
OTEL_EXPORTER_OTLP_ENDPOINTthe server exports metrics (meterZplCore.Serverand the ASP.NET Core/runtime meters), traces (without query strings, without probes) and logs over OTLP; the image setsOTEL_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, size | one native binary, starts fast, smaller image | .NET runtime, slower start |
| Oracle | – | with your own driver |
| SQL Server | SQL logins | also Entra ID / managed identity, integrated security, Always Encrypted |
| ZPL generation of records in memory | on all cores (output:generationThreads 0) | serial (byte-identical output) |
| Platforms | linux/amd64, linux/arm64 | linux/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(orauth:apiKeyHeader), or the JWT has the wrong audience, issuer or role. - 406 –
Acceptnames an unsupported type, or PNG/SVG for several labels (useapplication/ziporapplication/pdf). - 413 – image outputs above
limits:maxRenderLabels(use ZPL for large jobs), an image above the image limits, or a body abovelimits:maxRequestBodyBytes. - 422 – read
titleanddetail: “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 thetraceId), “Invalid template”, “Printer twin check failed”. - 429 – all render slots busy: raise
limits:maxConcurrentRenders/maxQueuedRendersor add replicas; without a body: the rate limit. - 502 – a template source, database, REST service or printer cannot be reached; the
detailnames which one (never credentials). - 504 –
limits:requestTimeout, a querytimeout, or a printer that does not answer~HS.
Templates are not loaded
GET /v1/templateslists files that could not be loaded with the reason (errors), and every source with its last error;/ready(authenticated) showsproblems.- Only
*.jsonfiles 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
prefixright, and are there more thanmaxObjectsobjects below it? git-sync: ispollshort 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 withtwin: block;failedafter 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:
dpiandmodelof the printer inzplcore.yamlmust match the device.

