zplCore.comzplCore.com

Dokumentation · zplCore Server

zplCore Server
Vorlage und Daten rein, Etiketten raus.

zplCore Server ist die Engine von zplCore.com als fertiger Container mit REST-API. Die Etikettenvorlagen liegen auf dem Server; Ihre Anwendung schickt nur die Daten – oder der Server holt sie selbst über eine benannte Datenbank- oder REST-Abfrage – und bekommt ZPL, PNG, SVG, PDF, ein ZIP oder einen JSON-Bericht zurück, oder die Etiketten gehen direkt an einen Netzwerkdrucker. Ein Container, konfiguriert mit einer YAML-Datei oder Umgebungsvariablen, kein Code. Diese Seite beschreibt den Schnellstart, jeden Konfigurationsschlüssel, alle Endpunkte, Vorlagen- und Datenquellen, Drucken, Ablage, Jobs und Webhooks, Sicherheit und Betrieb.

Schnellstart Endpunkte Konfiguration Produktseite

01Überblick

Noch nicht veröffentlicht – verfügbar ab Version 1.0. Das Image erscheint auf Docker Hub als zplcore/server mit Versions-Tags wie 1.0.0; einen Termin gibt es noch nicht. API, Konfiguration und Verhalten auf dieser Seite sind gegen die aktuelle Vorabversion des Servers geprüft; die Docker-Befehle beschreiben das Image, wie es für 1.0 gebaut wird. Namen und Standardwerte können sich bis dahin noch ändern. Mehr auf der Produktseite.

So funktioniert es

So arbeitet zplCore Server Vorlagen aus einem Volume, Git, S3, Azure Blob, Google Cloud Storage oder per HTTP und Daten aus dem Request, einer Datenbank oder einem REST-Dienst gehen in zplCore Server. Heraus kommen ZPL, PNG, SVG, PDF, ZIP oder JSON als Antwort, ein Druckauftrag an einem Netzwerkdrucker, eine abgelegte Datei und ein signierter Webhook. Vorlagen Volume · ConfigMap · Git S3 / MinIO · Azure Blob · GCS · HTTP Daten JSON · NDJSON · CSV im Request SQL und REST: benannte Abfragen zplCore Server REST-API /v1 · Vorlage + Datensätze → Etiketten Container · NativeAOT · zplcore.yaml Antwort ZPL · PNG · SVG · PDF · ZIP · JSON Drucker TCP 9100 · vom Printer Twin geprüft Ablage Volume · S3 · Azure Blob · GCS Webhook signiert, wenn ein Job endet
Vorlagen und Daten rein; die Etiketten kommen in der Antwort zurück, gehen an einen Drucker, in eine Ablage – und ein Webhook meldet das Ergebnis.
  • Vorlagen sind dieselben JSON-Dateien wie im SDK: Label-JSON (Label.ToJson), Druckaufträge mit Start- und Endetikett (PrintJobTemplate) oder Dokumente des zplCore Designers. Aufgerufen werden sie über den Pfad, z. B. shipping/label-100x150, versioniert über ihren Inhalt.
  • Daten kommen mit dem Request (JSON, NDJSON, CSV) oder aus einer benannten Abfrage, die der Server nur lesend gegen eine Datenbank oder einen REST-Dienst ausführt. Felder, Ausdrücke, Zähler und Formate funktionieren wie im SDK.
  • Ausgaben: ZPL wird als Strom geschrieben, Datensatz für Datensatz – auch für 100.000 Etiketten und mehr; PNG, SVG, PDF und ZIP rendert der Renderer von zplCore.com; ein JSON-Bericht liefert Anzahlen und SHA-256. Die Etiketten können auch an einen konfigurierten Drucker gehen, abgelegt werden und einen Webhook auslösen – synchron oder als asynchroner Job.
  • Betrieb: kein eigener Zustand, Health- und Readiness-Probes, Prometheus-Metriken, OpenTelemetry, OpenAPI mit interaktiver API-Doku – und ein Offline-Lizenzschlüssel ohne Phone-home.

Server oder SDK?

zplCore SDK (NuGet)zplCore Server (Container)
Läuftin Ihrer .NET-Anwendungals eigener Dienst, per HTTP aus jeder Sprache aufgerufen
Vorlagenlädt Ihr Codeauf dem Server: Volume, Git, S3, Azure Blob, GCS, HTTP – neu geladen und versioniert
DatenIhre Objekte, EF Core, LiteDB …JSON, NDJSON, CSV oder benannte SQL-/REST-Abfragen – ohne Code
DruckenIhr Code (TCP, USB, Spooler, CUPS)konfigurierte Netzwerkdrucker (TCP 9100) mit Warteschlange und Printer Twin
Passt für.NET-Teams, die volle Kontrolle wollenTeams ohne .NET, zentrale Etikettendienste, Kubernetes

02Schnellstart

Images und Tags

Tag (geplant)BuildPlattformenWann
zplcore/server:1.0.0NativeAOT – ein Programm, startet schnell, kleinlinux/amd64, linux/arm64der Standard
zplcore/server:1.0.0-fullJIT (.NET-Runtime)linux/amd64, linux/arm64eigener Oracle-Treiber; SQL Server mit Entra ID, integrierter Sicherheit oder Always Encrypted
zplcore/server:1.0.0-arm32v7JITlinux/arm/v732-Bit-ARM (NativeAOT kennt kein 32-Bit-ARM)

Alle Images lauschen auf Port 8080, laufen als Benutzer app (UID 1654, nicht root) und enthalten keine Shell. Die Standardkonfiguration liest Vorlagen aus /templates.

Erste Etiketten

Legen Sie eine Vorlage in ./templates ab: die Datei templates/shipping/label-100x150.json wird als shipping/label-100x150 aufgerufen. Der Anmeldemodus ist Pflicht – es gibt keinen Standard, und ohne ihn beendet sich der Container sofort (Exit-Code 2) mit einer Meldung, die ZPLCORE__AUTH__MODE nennt.

# 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"}

Im Betrieb API-Schlüssel (mindestens 16 Zeichen) oder JWT verwenden (siehe Anmeldung):

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"}'

Die eigene zplcore.yaml wird unter /config/zplcore.yaml eingehängt; http://localhost:8080/docs zeigt die interaktive API-Referenz.

Lizenzschlüssel

Der Server prüft denselben Offline-Lizenzschlüssel wie das SDK (Lizenzschlüssel) – lokal, nie über das Netz. Er sucht in dieser Reihenfolge:

  1. ZPLCORE__LICENSE__KEY – der Schlüssel selbst als Umgebungsvariable,
  2. die Datei license:file, Standard /run/secrets/zplcore-license (ein Docker- oder Kubernetes-Secret) – alle 30 Sekunden geprüft, ein erneuerter Schlüssel wirkt also ohne Neustart,
  3. die Quellen des SDK: ZPLCORE_LICENSE, ZPLCORE_LICENSE_FILE, eine Datei zplcore.license neben dem Programm.
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

Ohne gültigen Schlüssel läuft der Server im Testmodus, ohne Zeitgrenze: alles funktioniert, aber die Ausgaben sind markiert – ZPL mit mehreren kleinen Feldern zplCore.com TRIAL je Etikett, PNG und PDF mit Wasserzeichen, SVG nur als Bild. Antworten der Etiketten-Endpunkte tragen X-ZplCore-License: licensed oder evaluation, der JSON-Bericht das Feld license; /ready zeigt angemeldeten Aufrufern Zustand, Edition und Wartungsende (nie den Schlüssel oder den Lizenznehmer). Ein beschädigter Schlüssel stoppt den Server nicht; er läuft im Testmodus und schreibt den Grund ins Log.

03Konfiguration: zplcore.yaml

Der Server liest eine Konfigurationsdatei, die erste gefundene:

  1. --config <datei> auf der Kommandozeile,
  2. die Umgebungsvariable ZPLCORE_CONFIG,
  3. /config/zplcore.yaml (dort die eigene einhängen),
  4. die zplcore.yaml neben dem Programm – der Standard des Images (Volume-Quelle /templates, Doku an, Metriken aus).

Jeder Schlüssel lässt sich auch als Umgebungsvariable ZPLCORE__ABSCHNITT__SCHLÜSSEL setzen, die Vorrang vor der Datei hat: ZPLCORE__AUTH__MODE=apikey, ZPLCORE__LIMITS__MAXRENDERLABELS=500; Listeneinträge und benannte Einträge mit Index bzw. Name: ZPLCORE__AUTH__APIKEYS__0, ZPLCORE__TEMPLATES__SOURCES__0__PATH, ZPLCORE__WEBHOOKS__ERP__SECRET. Variablen ohne das Präfix ZPLCORE__ werden nicht als Konfiguration gelesen (außer den hosteigenen ASPNETCORE_…/DOTNET_… und OTEL_…). Zeitangaben schreiben Sie als hh:mm:ss (00:00:30), Größen in Bytes. Die Datei wird beim Start gelesen; Vorlagen und die Lizenzdatei werden im laufenden Betrieb neu geladen. Eine fehlende, ungültige oder unvollständige Einstellung stoppt den Start mit Exit-Code 2 und einer Meldung, die den Schlüssel nennt.

Secrets

API-Schlüssel, der Lizenzschlüssel, der Metrik-Schlüssel, Datenbank-Verbindungszeichenfolgen, REST-Tokens, -Passwörter und -Header-Werte, Cloud-Schlüssel (Secret Access Key, Session Token, Storage-Verbindungszeichenfolge, SAS-Token, Bearer-Token, Basic Auth), Webhook-Schlüssel und die Redis-Verbindungszeichenfolge stehen nie in zplcore.yaml. Jeder hat einen Schlüssel …File (ein Docker- oder Kubernetes-Secret, z. B. connectionStringFile: /run/secrets/wms-db; der Zeilenumbruch am Ende wird entfernt) oder kommt aus der Umgebung (ZPLCORE__DATA__CONNECTIONS__WMS__CONNECTIONSTRING). Ein Klartext-Wert in der Datei stoppt den Start mit Exit-Code 2 und nennt Schlüssel und Alternativen; Wert und Datei für denselben Schlüssel zugleich sind ebenfalls ein Fehler. Secrets erscheinen nie in Logs, Fehlerantworten, /ready oder den Katalogen.

Ausnahme – die Anmeldeketten der Cloud-Plattformen. Sind keine Schlüssel konfiguriert, nutzen die Cloud-Quellen und -Ablagen die Standardvariablen, die die Plattform setzt: AWS – AWS_REGION/AWS_DEFAULT_REGION und die AWS-Standardkette (AWS_ACCESS_KEY_ID …, EKS IRSA/Pod Identity, ECS- und EC2-Rollen); Google – GOOGLE_APPLICATION_CREDENTIALS und der Metadatenserver (GCE_METADATA_HOST, GKE Workload Identity, Cloud Run); Azure mit identity: managed oder workload – IDENTITY_ENDPOINT/IDENTITY_HEADER, AZURE_POD_IDENTITY_AUTHORITY_HOST, AZURE_FEDERATED_TOKEN_FILE, AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_AUTHORITY_HOST. Sie tragen nur Anmeldedaten und Endpunkte der Plattform, keine Server-Einstellungen.

Referenz aller Schlüssel

„nur Umgebung“ = der Wert nur aus einer Umgebungsvariablen (oder dem passenden Schlüssel …File), nie in der Datei. Leerer Standard = nicht gesetzt.

auth – Anmeldung

SchlüsselStandardBedeutung
modePflichtapikey, jwt (auch oidc) oder none
apiKeyHeaderX-Api-KeyHeader mit dem API-Schlüssel
apiKeysnur Umgebung: ZPLCORE__AUTH__APIKEYS__0, __1 …; je mindestens 16 Zeichen
apiKeysFileDatei mit einem Schlüssel je Zeile, # leitet einen Kommentar ein
authorityOIDC-Issuer (Modus jwt, Pflicht)
audienceerwartete Audience des Tokens (Modus jwt, Pflicht)
requiredRoleRolle, die das Token tragen muss (Claim roles oder role)
requireHttpsMetadatatrueOIDC-Metadaten nur über https

license

SchlüsselStandardBedeutung
file/run/secrets/zplcore-licenseDatei mit dem Lizenzschlüssel, alle 30 s geprüft
keynur Umgebung: ZPLCORE__LICENSE__KEY

templates

SchlüsselStandardBedeutung
sourcesImage: Volume /templatesListe der Vorlagenquellen in Prioritätsreihenfolge (Schlüssel unten)
keepVersions10frühere Versionen, die je Vorlage gehalten werden (abrufbar als pfad@version)
inline:enabledfalseschaltet POST /v1/render ein (Vorlage im Request)

Schlüssel einer Quelle (templates:sources:N):

SchlüsselStandardTypenBedeutung
namealleName der Quelle (Buchstaben, Ziffern, ., _, -); name:pfad fragt diese Quelle
typevolumeallevolume, s3, azureblob, gcs, http
pollVolume 10 s, sonst 30 salleAbfrage-Intervall
timeout00:00:30entferntlängste Dauer einer Anfrage an die Quelle
pathvolumeOrdner, schreibgeschützt eingehängt
bucket / containers3, gcs / azureblobBucket bzw. Container
prefixs3, azureblob, gcsSchlüssel-Präfix, z. B. prod/: prod/shipping/x.json → shipping/x
serviceUrlder Cloud-Diensts3, azureblob, gcsS3-kompatibler Endpunkt (MinIO), Azure-Konto-URL, anderer GCS-Endpunkt
maxObjects10000s3, azureblob, gcshöchstens so viele Objekte je Auflistung (0 = keine Grenze)
regionAWS_REGIONs3AWS-Region; mit serviceUrl die Signatur-Region (Standard us-east-1)
pathStylefalses3Path-Style-Anfragen (MinIO und die meisten S3-kompatiblen Speicher)
accessKeyId / accessKeyIdFiles3Access Key ID; ohne Schlüssel die AWS-Standardkette
secretAccessKey / secretAccessKeyFiles3Secret Access Key (Wert nur Umgebung)
sessionToken / sessionTokenFiles3Session Token temporärer Zugangsdaten (Wert nur Umgebung)
connectionString / connectionStringFileazureblobStorage-Verbindungszeichenfolge (Wert nur Umgebung)
sasToken / sasTokenFileazureblobSAS-Token für serviceUrl (Wert nur Umgebung)
identityazureblobmanaged (Managed Identity) oder workload (AKS Workload Identity), mit serviceUrl
clientId / tenantIdAZURE_CLIENT_ID / AZURE_TENANT_IDazureblobbenutzerzugewiesene Identität bzw. Workload Identity
credentialsFileGOOGLE_APPLICATION_CREDENTIALSgcsDienstkonto-Schlüssel (JSON); ohne ihn der Metadatenserver
anonymousfalsegcsohne Anmeldung (Emulator, öffentlicher Bucket)
baseUrlhttpBasis-URL; shipping/x ist <baseUrl>shipping/x.json
allowedHostsHost der baseUrlhttpHosts, die die Quelle anfragen darf, Weiterleitungen eingeschlossen
allowedNetworkshttpNetze (CIDR), die erreicht werden dürfen, obwohl privat, Loopback oder Link-Local
allowHttpfalsehttpreines http erlauben
bearerToken / bearerTokenFilehttpBearer-Token (Wert nur Umgebung)
basicAuth / basicAuthFilehttpuser:password (Wert nur Umgebung)

limits

SchlüsselStandardBedeutung
maxRequestBodyBytes268435456 (256 MiB)größter Request-Body (413)
maxRecordsPerRequest1000000Datensätze je Anfrage (auch alle Teile eines Batches zusammen)
maxRenderLabels1000Etiketten je Anfrage für PNG, SVG, PDF, ZIP und JSON mit Diagnosen (413)
maxTemplateBytes8388608 (8 MiB)größte Vorlagendatei
maxConcurrentRenders0 = ProzessorenRender-Slots
maxQueuedRenders64Anfragen, die auf einen Slot warten; mehr: 429
queueTimeout00:00:30längste Wartezeit auf einen Slot; dann 429
requestTimeout00:10:00längste Dauer einer Anfrage (504)
rateLimitPermits0 = ausAnfragen je Fenster und Client
rateLimitWindow00:01:00Fenster des Rate-Limits
maxLabelSide8000größte Etikettenseite in Dots (auch ^PW/^LL)
maxFieldDataLength65536Zeichen eines Feldes
maxImagePixels40000000größtes Bild (Pixel), das ein Bild, QR-Logo oder Barcode-Art dekodiert; 0 = keine Grenze
maxImageBytes10485760 (10 MiB)größtes Rasterbild einer Vorlage oder eines Datensatzes; 0 = keine Grenze
maxSvgBytes2097152 (2 MiB)größtes SVG einer Vorlage oder eines Datensatzes; 0 = keine Grenze

output

SchlüsselStandardBedeutung
defaultDpi203Auflösung, wenn die Anfrage keine nennt (100–1200)
storedFormatMinRecords1000ab so vielen Datensätzen (Anzahl vorab bekannt) wird das Datensatz-Etikett einmal im Drucker abgelegt (^DF/^XF, druckt gleich); 0 = nie
storedFormatNameR:ZPLCORE.ZPLName dieses Stored Formats
fontFoldersOrdner mit TTF-/OTF-Dateien, die Vorlagen nennen dürfen (Schriften)
fontsgraphicgraphic oder printer: wie Text in TrueType-Schriften zum Drucker kommt (Schriften)
generationThreads0 = automatischThreads, die die ZPL einer Anfrage mit Datensätzen im Speicher erzeugen: Prozessoren im NativeAOT-Image, 1 unter JIT; 1 = seriell

docs, metrics

SchlüsselStandardBedeutung
docs:enabledtrue/docs (Scalar) und /openapi/v1.json
metrics:enabledfalse/metrics im Prometheus-Format
metrics:porteigener Port für /metrics (z. B. 9464), ohne weitere Prüfung
metrics:apiKeynur Umgebung: Schlüssel, der /metrics auf dem Hauptport öffnet

data – benannte Abfragen

data:connections:<name>:

SchlüsselStandardBedeutung
providersqlite, postgresql, sqlserver, mysql (auch MariaDB), oracle (-full-Image mit Ihrem Treiber) oder rest
requiredfalse/ready antwortet 503, solange die Verbindung nicht erreichbar ist
connectionString / connectionStringFileDatenbanken (Wert nur Umgebung)
baseUrlREST: Basisadresse; Abfragepfade sind relativ dazu
allowedHostsHost der baseUrlREST: weitere Hosts für Seiten-Links und Weiterleitungen
allowedNetworksREST: Netze (CIDR), die erreicht werden dürfen, obwohl privat, Loopback oder Link-Local
allowHttpfalseREST: reines http erlauben
auth:typenoneREST: bearer, basic, header
auth:token / auth:tokenFileBearer-Token (Wert nur Umgebung)
auth:username, auth:password / auth:passwordFileBasic (Passwort nur Umgebung)
auth:header, auth:value / auth:valueFileeigener Header, z. B. X-Api-Key (Wert nur Umgebung)

data:queries:<name>:

SchlüsselStandardBedeutung
connectionName der Verbindung
sqlDatenbanken: eine SELECT-Anweisung (oder WITH … SELECT); Parameter @name (Oracle :name)
countSqlDatenbanken, optional: die Zeilenzahl von sql (gleiche Parameter)
pathREST: Pfad und Query relativ zur baseUrl; {name} = URL-kodierter Parameter
items$REST: JSON-Pfad zum Array der Datensätze, z. B. $.items
paging:nextREST: link (Link-Header rel="next") oder ein JSON-Pfad wie $.next
paging:maxPages100REST: höchstens so viele Seiten je Aufruf
parameters:<name>:typestringstring, int, long, decimal, double, bool, date (yyyy-MM-dd), datetime (ISO 8601)
parameters:<name>:requiredfalseder Parameter muss angegeben werden
parameters:<name>:patternZeichenketten: regulärer Ausdruck für den ganzen Wert
maxRows0 = limits:maxRecordsPerRequesthöchstens so viele Zeilen je Aufruf; mehr beenden die Anfrage mit einem Fehler
timeout00:00:30längste Dauer eines Aufrufs (alle Zeilen, alle Seiten)
maxBytes0 = limits:maxRequestBodyByteshöchstens so viele Daten je Aufruf

printers, printing

printers:<name>:

SchlüsselStandardBedeutung
hostHostname oder IP-Adresse des Druckers
port9100Raw-TCP-Port
modelModell, z. B. ZD421; mit dpi wählt es das Printer-Twin-Profil
profileaus model + dpi, sonst generischPrinter-Twin-Profil, z. B. ZD421d-203
dpiProfil, sonst output:defaultDpiAuflösung, mit der die Etiketten erzeugt werden
optionseingebaute Optionen für den Twin: cutter, peeler, rewinder, rfid, linerless, applicator, realtimeclock
twinwarnoff, warn oder block
fontsoutput:fontsgraphic oder printer für diesen Drucker
timeout00:00:10Verbindungsaufbau und ~HS
sendTimeout00:01:00längste Zeit ohne Fortschritt beim Senden
retries2weitere Versuche – nur vor dem ersten Byte
retryDelay00:00:01Wartezeit vor dem ersten erneuten Versuch, je weiterem verdoppelt
queueLimit100Aufträge, die auf diesen Drucker warten; mehr: 503
checkStatusfalse~HS vor jedem Auftrag; nicht bereit = nicht gesendet, wiederholt

printing:twinSampleLabels (20): Etiketten eines Auftrags, die der Printer Twin vor dem Senden prüft. printing:maxCopies (1000): Obergrenze von ?copies= (auch Kopien × ein Kopienfeld der Vorlage).

sinks, webhooks

sinks:<name>: type (volume; auch s3, azureblob, gcs), path (Volume), pattern ({date}/{jobId}.{ext}; Platzhalter {date}, {time}, {jobId}, {template}, {ext}). Cloud-Ablagen nehmen die Schlüssel der Vorlagenquelle desselben Typs (bucket/container, prefix, serviceUrl, region, pathStyle, Secret-Dateien, identity); Secrets wie dort, z. B. ZPLCORE__SINKS__ARCHIVE__SECRETACCESSKEY.

webhooks:<name>:StandardBedeutung
urlEmpfänger (https)
secretFile / secretSignaturschlüssel, mindestens 16 Zeichen (Wert nur Umgebung: ZPLCORE__WEBHOOKS__<NAME>__SECRET)
timeout00:00:10je Versuch
retries5weitere Versuche nach Netzfehlern, Zeitüberschreitung, 408, 429, 5xx
retryDelay00:00:02erste Wartezeit, jedes Mal verdoppelt (höchstens 5 Minuten)
allowHttpfalsereines http erlauben
allowPrivateNetworksfalseprivate Netze und Loopback erlauben
allowedNetworksNetze (CIDR), die erlaubt sind, obwohl sonst gesperrt (auch Link-Local)

jobs, batch

SchlüsselStandardBedeutung
jobs:enabledtrueasynchrone Jobs und /v1/jobs
jobs:workers0 = Prozessorengleichzeitig laufende Jobs auf dieser Instanz
jobs:queueCapacity100wartende Jobs auf dieser Instanz; mehr: 429
jobs:sinkAblage für Job-Status und Ergebnisse (jobs/{id}/…); ohne sie: Speicher, eine Instanz
jobs:ttl24:00:00wie lange Status und Ergebnis erhalten bleiben
jobs:cleanupInterval00:10:00wie oft abgelaufene Jobs gelöscht werden
jobs:spoolPathTemp-Ordnerlokaler Ordner für Request-Bodys und Ausgaben, solange ein Job läuft
jobs:redis:connectionString / connectionStringFileJob-Status zusätzlich in Redis (braucht jobs:sink; Wert nur Umgebung)
jobs:redis:keyPrefixzplcore:jobs:Präfix der Redis-Schlüssel
batch:maxParts1000Teile eines Batches

Vollständiges Beispiel

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

04Anmeldung und Rate-Limit

ModusWieFür
apikeySchlüssel im Header X-Api-Key (auth:apiKeyHeader); Schlüssel aus ZPLCORE__AUTH__APIKEYS__0, __1 … oder auth:apiKeysFile, mindestens 16 Zeichen, als SHA-256 in fester Zeit verglichenAufrufe von Dienst zu Dienst
jwt (oidc)Bearer-Token Ihres Identity Providers; auth:authority und auth:audience sind Pflicht (ohne Audience startet der Server nicht: Tokens desselben Issuers für andere Anwendungen kämen sonst durch); optional auth:requiredRoleEntra ID, Keycloak, Auth0 …
nonekeine Prüfung – jeder, der den Server erreicht, darf ihn aufrufen; Warnung im Loglokale Tests oder hinter einem eigenen Gateway, das anmeldet
# 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"

Ohne Anmeldung: /health, /ready (nur der Status), /docs, /openapi/v1.json. Alles unter /v1 braucht sie; ein fehlender oder falscher Schlüssel ergibt 401 ohne Body. /metrics hat eine eigene Regel (Metriken).

Rate-Limit: Mit limits:rateLimitPermits > 0 darf jeder Client so viele Anfragen je limits:rateLimitWindow an /v1 senden; weitere bekommen 429 ohne Body. Ein Client ist die angemeldete Identität – der API-Schlüssel oder das JWT-Subject (sub, sonst azp/client_id) –, sonst die IP-Adresse; nie ein Header, den der Client frei wählen kann.

05Endpunkte

Alle Endpunkte unter /v1 brauchen eine Anmeldung. Antworten sind JSON mit camelCase-Namen; Fehler sind application/problem+json auf Englisch (Fehler). Die interaktive Referenz des laufenden Servers steht unter /docs, das OpenAPI-Dokument unter /openapi/v1.json.

MethodePfadZweck
POST/v1/labels/{template}eine Vorlage mit Datensätzen füllen, die Etiketten zurückgeben (oder drucken, ablegen, melden)
POST/v1/renderdasselbe mit der Vorlage im Request (standardmäßig aus)
GET/v1/templatesKatalog: Vorlagen, Versionen, Fehler, Quellen
GET/v1/templates/{path}, …/previeweine Vorlage als JSON; PNG ihres ersten Etiketts
GET/v1/queriesbenannte Abfragen mit ihren Parametern
POST/v1/print/{printer}ZPL drucken oder eine Vorlage mit Datensätzen
GET/v1/printers, /v1/printers/{name}/statusDrucker mit Warteschlange; Live-Status (~HS)
POST · GET · DELETE/v1/jobs, /v1/jobs/{id}, /v1/jobs/{id}/resultasynchrone Jobs
POST/v1/batchmehrere Vorlagen und Datensatzgruppen in einer Anfrage
GET/health, /ready, /metrics, /docs, /openapi/v1.jsonBetrieb

POST /v1/labels/{template}

{template} ist der Vorlagenpfad, optional mit Quelle und Version: shipping/label-100x150, shared:shipping/label-100x150, shipping/label-100x150@61581b863bd9. Der Body enthält die Datensätze (Formate); der Header Accept wählt die Ausgabe:

AcceptAntwort
application/vnd.zpl (Standard; auch text/plain, */*)ZPL, Datensatz für Datensatz als Strom
image/png, image/svg+xmlgenau ein Etikett; mehrere → 406
application/pdfein PDF, eine Seite je Etikett
application/zipein Bild je Etikett (label00001.png …), während des Renderns geschrieben
application/jsonBericht { template, version, records, labels, zplBytes, sha256, license, diagnostics?, zpl? }
Query-ParameterBedeutung
dpi100–1200, Standard output:defaultDpi
versionVorlagenversion (Inhalts-Hash oder die Versionskennung der Quelle)
formatZIP-Einträge: png (Standard) oder svg
diagnosticstrue: der JSON-Bericht rendert die Etiketten und listet die Diagnosen des Renderers
zpltrue: der JSON-Bericht enthält die ZPL
copiesdruckt jedes Etikett so oft (^PQ), 1 bis printing:maxCopies
fontsgraphic oder printer (Schriften)
delimiterCSV-Trennzeichen: , ; tab | (Standard: erkannt)
query + ihre ParameterDatensätze aus einer benannten Abfrage, z. B. ?query=open-shipments&country=AT
print, twinan einen konfigurierten Drucker senden; Twin-Prüfung off/warn/block
store, webhookin eine Ablage schreiben; einen Webhook benachrichtigen
asynctrue (oder Header Prefer: respond-async): als Job ausführen, Antwort 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
…

Antwort-Header: X-Template-Version (und X-Template-Source-Version, wenn die Quelle eine eigene Versionskennung hat), X-ZplCore-License, X-Record-Count, wenn die Anzahl vorab bekannt ist; PDF und ZIP mit Content-Disposition, SVG mit Content-Security-Policy: sandbox. Mit print, store, webhook oder async ist die Antwort stattdessen der Job-Status. Fehler: 400, 401, 404, 406, 413, 415, 422, 429, 502, 504.

POST /v1/render

Nur mit templates:inline:enabled: true. Body { "template": { … }, "records": [ … ] } – die Vorlage ist ein Label, ein Druckauftrag oder ein Designer-Dokument; ohne records ein leerer Datensatz. Ausgaben und die Parameter dpi, format, diagnostics, zpl, fonts wie bei /v1/labels. Eine Inline-Vorlage liest nie Dateien auf dem Server: ein Bild mit sourceFile ergibt 400 – schicken Sie das Bild in imageData mit; Schriften müssen in der Vorlage eingebettet sein.

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 listet jede Vorlage mit ihren gehaltenen Versionen, die Dateien, die nicht geladen werden konnten (mit Grund), und die Quellen (Ort ohne Zugangsdaten, geladen, Anzahl, letzte Synchronisierung, 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} liefert das Vorlagen-JSON (bei einem Designer-Dokument dessen Etikett) mit X-Template-Version; ?version= oder @version wählt eine Version. GET /v1/templates/{path}/preview liefert ein PNG des ersten Etiketts mit den Beispieldaten der Vorlage (Designer-Dokumente) oder einem leeren Datensatz. Fehler: 404, 413, 422, 429, 502.

GET /v1/queries

Die konfigurierten benannten Abfragen mit ihren Parametern – nie SQL, URL oder Verbindung:

{ "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}

Druckt auf einem konfigurierten Drucker. Der Body ist entweder ZPL (application/vnd.zpl, text/plain, application/octet-stream), unverändert gesendet, oder – mit ?template= – die Datensätze (JSON, NDJSON, CSV), erzeugt mit der dpi des Druckers. Parameter: template, version, query (+ Parameter), delimiter, dpi, copies und fonts (nur mit template), twin, store, webhook, async. Die Antwort ist der Job-Status: 200, wenn gesendet, oder ein Problem, dessen instance die Status-URL ist.

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" }

Fehler: 400, 404 (Drucker, Vorlage), 409, 415, 422 (Twin block, Kopien), 429, 502 (Drucker nicht erreichbar), 503 (Warteschlange voll).

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 fragt den Drucker über eine eigene Verbindung (~HS); sendet der Drucker gerade den Auftrag einer anderen Verbindung, antwortet er danach. 502 = nicht erreichbar, 504 = keine vollständige Antwort innerhalb von 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

AnfrageAntwort
POST /v1/jobs202, Location: /v1/jobs/{id}, der Status; 400 (unbekannte Eigenschaft), 404, 429 (Warteschlange voll), 503 (Status nicht speicherbar)
GET /v1/jobs/{id}der Status; 404 unbekannt oder abgelaufen
GET /v1/jobs/{id}/resultdie Ausgabe mit ETag = SHA-256; 409, solange der Job nicht erfolgreich war; 404 bei einer synchronen Auslieferung (nur ihr Status wird gehalten)
DELETE /v1/jobs/{id}202 = ein wartender oder laufender Job wird abgebrochen (auch auf einer anderen Instanz); 204 = ein fertiger Job wird gelöscht

Job-Dokument von POST /v1/jobs – unbekannte Eigenschaften werden abgelehnt:

EigenschaftBedeutung
template, versionVorlagenpfad und Version
records oder queryDatensätze (Array oder ein Objekt) oder { "name": …, "parameters": { … } }
variablesAuftragsvariablen (Start- und Endetikett)
zplZPL, die unverändert gedruckt oder abgelegt wird (statt einer Vorlage)
partsein Batch: [ { "template", "records", "variables", "version", "copies" }, … ]
format, zipEntrieszpl (Standard), png, svg, pdf, zip, json; ZIP-Einträge png/svg (Batch zpl/pdf)
dpi, copies, fonts, diagnostics, includeZplwie die Query-Parameter von /v1/labels
print, twin, store, webhookDrucker, Twin-Modus, Ablage, Webhook mit Namen
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" }

Zustände: queued, running, succeeded, failed, canceled; ein gescheiterter Job trägt error { status, title, detail } – das Problem, das eine synchrone Anfrage bekommen hätte.

POST /v1/batch

Mehrere Vorlagen und Datensatzgruppen in einer Anfrage. Body application/json { "parts": [ … ] } (oder das Array selbst) – alle Teile werden vor dem ersten Byte geprüft – oder application/x-ndjson, ein Teil je Zeile, Teil für Teil gelesen. Ein Teil: { "template", "records", "variables"?, "version"?, "copies"? }. Accept: application/vnd.zpl (Standard; ein Strom, Teile in Reihenfolge), application/zip (ein Eintrag je Teil, ?format=zpl oder pdf) oder application/json (Bericht je Teil). Parameter: dpi, copies (Standard der Teile), 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" } ] }

Grenzen: batch:maxParts, limits:maxRecordsPerRequest für alle Teile zusammen, limits:maxRenderLabels für PDF-Einträge. ZIP-Einträge heißen part00001-shipping_label-100x150.pdf … Fehler wie bei /v1/labels; ein Teil nennt seine Nummer („Part 1: template … does not exist“).

Health, Metriken, OpenAPI

PfadAntwort
GET /healthok – Liveness, ohne Anmeldung
GET /ready200 {"status":"ready"} oder 503 {"status":"not-ready"}; mit Anmeldung die Details unten
GET /metricsPrometheus-Textformat; standardmäßig aus (Metriken)
GET /docsinteraktive API-Referenz (Scalar), vom Server selbst ausgeliefert – kein CDN
GET /openapi/v1.jsonOpenAPI-Dokument
{ "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" } }

Fehler

Fehler sind application/problem+json (RFC 9457) mit title, status, detail und meist einer traceId – Texte auf Englisch. 401, 415 und das 429 des Rate-Limits haben keinen Body. Ein Serverfehler (500) enthält nie interne Details: das Log hat sie unter der 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" }
StatusTypischer TitelUrsache
400Invalid records · Invalid dpi · Invalid copies · Invalid query parameters · Local files not allowedJSON/CSV/NDJSON nicht lesbar, ein Parameter außerhalb des Bereichs, ein Muster nicht erfüllt, sourceFile in einer Inline-Vorlage
401–kein oder falscher API-Schlüssel / Token
404Template not found · Query not found · Printer not found · Sink not found · Webhook not found · Job not foundunbekannter Name, unbekannte Version oder abgelaufener Job
406Unsupported Accept header · More than one labelAccept nicht unterstützt; PNG/SVG für mehrere Etiketten; ZIP beim Drucken
409Job not finished · Job did not succeedErgebnis zu früh oder von einem gescheiterten Job angefordert
413Too many labels · Image too largelimits:maxRenderLabels; ein Bild über maxImagePixels/maxImageBytes/maxSvgBytes; Body über maxRequestBodyBytes
415–Content-Type nicht unterstützt
422Invalid template · Label generation failed · Unknown field · Font not available · Too many copies · Printer twin check failedVorlage ungültig; ein Wert oder Ausdruck, der sich nicht auswerten lässt (Format, GS1, EPC); ein Name auf dem Start-/Endetikett, der weder Auftragswert noch Variable ist; Bilddatei nicht gefunden; Twin block
429Server busy · Job queue fullalle Render-Slots belegt; Job-Warteschlange voll; Rate-Limit (ohne Body)
500Internal errorunerwartet – Details im Log unter der traceId
502Template source unavailable · Query failed · Printer not reachableQuelle, Datenbank, REST-Dienst oder Drucker nicht erreichbar
503Printer queue full · Job not accepted · Job lostqueueLimit; Job-Status nicht speicherbar; die Instanz eines Jobs ist ausgefallen
504–, Query timeout, Printer did not answerlimits:requestTimeout; timeout einer Abfrage; keine ~HS-Antwort

Tritt ein Fehler auf, nachdem die Antwort begonnen hat (ZPL-Strom, ZIP), bricht der Server die Verbindung ab – ein abgeschnittener Strom sieht nie vollständig aus. Vergleichen Sie X-Record-Count oder den SHA-256 des JSON-Berichts, wenn Sie Gewissheit brauchen.

06Vorlagenquellen

  • Formate: Label-JSON (Label.ToJson: ein Etikett je Datensatz), Druckauftrag-JSON (PrintJobTemplate: Datensatz-Etikett plus Start- und Endetikett, Auftragsname, Stored Format, Schlüssel- und Kopienfeld) und zplCore-Designer-Dokumente (deren Etikett; deren Datensätze sind die Beispieldaten der Vorschau). Die Art wird am JSON erkannt. Vorlagen werden streng gelesen: eine unbekannte Eigenschaft ist ein Fehler, ältere Schema-Versionen werden migriert.
  • Pfad: der Dateipfad unterhalb der Quelle ohne .json; Buchstaben, Ziffern, ., _, - und /. Ordner, die mit . beginnen, werden übersprungen (Kubernetes-ConfigMaps legen ihre Daten in ..data ab).
  • Priorität: bei mehreren Quellen gewinnt die erste in der Liste, die den Pfad hat; name:pfad fragt eine Quelle.
  • Bilddateien, die eine Vorlage nennt, werden nur aus dem eigenen Volume-Ordner der Vorlage gelesen; Vorlagen aus entfernten Quellen lesen keine lokalen Bilddateien. Schriften kommen aus der Vorlage, ihrem Ordner (oder fonts/) oder output:fontFolders (Schriften).

Versionen, Cache und Neuladen

  • Jede geladene Datei bekommt eine Version: die ersten 12 Hex-Ziffern ihres SHA-256 (X-Template-Version). Die letzten templates:keepVersions (10) Versionen bleiben im Speicher und sind als pfad@version oder ?version= abrufbar.
  • Quellen mit eigenen Versionen werden auch über deren Kennung gefragt: S3-VersionId, Azure-Blob-Versions-ID, GCS-Generation, HTTP-ETag, der Git-Commit eines git-sync-Checkouts (X-Template-Source-Version). Den Inhalts-Hash einer nicht mehr gehaltenen Version kann keine Quelle liefern (404).
  • Vorlagen werden im Speicher gehalten; ein Auftrag sieht nie eine halb geladene Vorlage. Volumes werden überwacht und abgefragt (10 s), entfernte Quellen abgefragt (30 s) – nur geänderte Objekte werden geladen. http lädt eine Vorlage beim ersten Aufruf.
  • Fehler: eine kaputte neue Datei ersetzt nie die laufende Version – der Fehler steht in GET /v1/templates und in /ready (problems). /ready antwortet 503, bis jede Quelle einmal gelesen wurde. Ist eine Quelle, die gefragt werden muss, nicht erreichbar, antwortet die Anfrage mit 502; die geladenen Vorlagen bleiben in Betrieb.
  • Object Stores (s3, azureblob, gcs) listen höchstens maxObjects (10.000) Objekte je Präfix; darüber lädt der Durchgang nichts und schreibt eine Warnung – ein Präfix, das versehentlich auf einen ganzen Bucket zeigt, lädt ihn nicht herunter.

Volume und 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

Symbolische Links werden nur verfolgt, solange der echte Pfad im Ordner bleibt; Link-Schleifen beenden das Lesen dieses Zweigs.

Git (git-sync)

Git ist nicht im Server eingebaut: ein git-sync-Sidecar klont das Repository in ein gemeinsames Volume und schaltet bei jedem neuen Commit einen Link um; der Server liest es als Volume-Quelle und meldet den Commit als Quellversion (?version=<commit>). Die Dateiüberwachung folgt dem Umschalten des Links nicht – deshalb ein kurzes 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) oder GITSYNC_SSH_KEY_FILE (Deploy-Key) am git-sync-Container. Unter Kubernetes läuft git-sync zusätzlich als Init-Container mit --one-time, damit der erste Checkout vor dem Serverstart da ist (Beispiel unter Kubernetes).

S3 und 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

Berechtigung: den Bucket unterhalb des Präfixes lesen (auflisten und abrufen). Mit Bucket-Versionierung sind ältere Versionen über ihre VersionId abrufbar.

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

Rolle der Identität: Storage Blob Data Reader für Quellen, Storage Blob Data Contributor für Ablagen. Der Server holt das Token selbst (keine zusätzliche Komponente); mit identity muss serviceUrl https sein (http nur zu einem lokalen 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)

Der Server liest mit einem Nur-Lese-Scope; die Generation eines Objekts ist seine Quellversion.

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

Eine Vorlage wird beim ersten Aufruf geladen und danach mit If-None-Match/If-Modified-Since abgefragt; 404/410 entfernt sie. SSRF-Schutz: nur https (außer mit allowHttp), keine Zugangsdaten in der URL, Hosts nur aus allowedHosts (Weiterleitungen eingeschlossen, höchstens 3, nie von https zu http), und jede aufgelöste Adresse wird vor dem Verbinden geprüft – private, Loopback- und Link-Local-Adressen nur über allowedNetworks (Sicherheit).

07Datenquellen

Datensätze im Request

Content-TypeDatensätze
application/jsonein Objekt, ein Array oder { "records": [ … ], "variables": { … } }; die Anzahl der Datensätze ist vorab bekannt – Summen auf einem Startetikett, X-Record-Count, Stored Formats ab output:storedFormatMinRecords
application/x-ndjsonein JSON-Objekt je Zeile, als Strom gelesen (die Anzahl ist vorab nicht bekannt)
text/csvKopfzeile = Feldnamen, RFC 4180 (Anführungszeichen, Zeilenumbrüche in Feldern in Anführungszeichen), als Strom gelesen; Trennzeichen erkannt oder ?delimiter=

Felder werden wie im SDK über den Namen gebunden (Datenbindung): verschachtelte Pfade (Address.City), Arrays, Ausdrücke. variables sind Werte für die Ausdrücke aller Etiketten des Auftrags, z. B. eine Tour auf dem Startetikett. Jeder Name, den das Start- oder Endetikett verwendet, muss ein Auftragswert (JobName, Created, RecordCount, LabelCount, FirstKey, LastKey, PAGE_NUMBER, TOTAL_PAGES …) oder eine Variable sein – sonst 422 „Unknown field“. Ein fehlendes Feld auf dem Datensatz-Etikett bleibt leer.

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

SQL-Datenbanken

Mit benannten Abfragen holt der Server die Datensätze selbst: in zplcore.yaml festgelegt, parametrisiert und nur lesend – es gibt kein Ad-hoc-SQL und keine freie URL. Eine Anfrage ruft sie über den Namen auf:

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"}}'
ProviderNur lesend durchHinweise
postgresqlTransaktion REPEATABLE READ, READ ONLYeingebaute skalare Typen (Text, Zahlen, bool, Datum/Zeit, uuid, bytea); andere im SQL umwandeln (col::text)
mysql (MariaDB)START TRANSACTION READ ONLYAllowLoadLocalInfile und AllowUserVariables werden abgelehnt
sqliteNur-Lese-Modus und query_onlyDatei aus einem Volume
sqlserver (Azure SQL)Transaktion, die immer zurückgerollt wird – das Login darf nur Leserechte haben (db_datareader); eine Warnung beim Start erinnert daranNativeAOT-Image: nur SQL-Logins; Entra ID / Managed Identity, integrierte Sicherheit und Always Encrypted brauchen -full
oracleSET TRANSACTION READ ONLYnur -full mit eigenem Treiber (unten); Parameter :name
  • Beim Start geprüft: genau eine SELECT-Anweisung (oder WITH … SELECT), keine schreibenden oder ausführenden Schlüsselwörter; jeder Platzhalter muss ein deklarierter Parameter sein und jeder Parameter verwendet werden. Parameter werden immer gebunden, nie als Text eingesetzt. Es wird nie etwas committet.
  • Parameter kommen aus dem Query-String oder aus parameters des Bodys, werden in ihren type umgewandelt und vor der Abfrage gegen required und pattern geprüft (400). Die Namen der eigenen Parameter der Etiketten-Endpunkte (template, dpi, copies, print …) sind nicht erlaubt.
  • countSql macht die Anzahl der Datensätze vor dem ersten Etikett bekannt: Summen auf dem Startetikett, X-Record-Count, Stored Formats, und maxRows wird geprüft, bevor etwas erzeugt wird.
  • Grenzen: maxRows, timeout (30 s, 504), maxBytes – mehr Zeilen beenden die Anfrage mit einem Fehler, nie ein gekürzter Auftrag. Die erste Zeile wird gelesen, bevor die Antwort beginnt; Verbindungs- und SQL-Fehler sind daher saubere Problem-Antworten (502).
  • Spaltentypen bleiben: Zeilen werden als Dictionarys gebunden, eine Datumsspalte formatiert also mit Format([ShipDate], "dd.MM.yyyy"). Spaltennamen müssen eindeutig und nicht leer sein (Aliase verwenden).
  • required: true: /ready antwortet 503, solange die Verbindung nicht erreichbar ist (mit kurzem Zeitlimit geprüft und kurz gecacht; keine Verbindungsdetails in der Antwort).
  • GET /v1/queries listet die Abfragen; asynchrone Jobs und das Drucken nutzen ?query= genauso.

Oracle

Der Oracle-Treiber ist in keinem Image enthalten. Bauen Sie ein eigenes Image auf zplcore/server:<version>-full mit Oracle.ManagedDataAccess.Core und seinen Abhängigkeiten in /app/drivers/oracle; dann funktioniert provider: oracle wie die anderen Provider. Ohne Treiber stoppt eine Verbindung mit provider: oracle den Start mit einem Hinweis. Für den Treiber gelten die Lizenzbedingungen von Oracle zwischen Ihnen und 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/

Von uns nicht als Docker-Build getestet.

REST-Dienste

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}" }

Die REST-Quelle nutzt denselben SSRF-Schutz wie die HTTP-Vorlagenquelle: nur https (außer mit allowHttp), Seiten-Links und Weiterleitungen nur zum Host der baseUrl und zu allowedHosts, private Adressen nur mit allowedNetworks; Parameter können die Basisadresse nicht verlassen. auth:type bearer, basic oder header mit Secrets aus Dateien oder der Umgebung. Datensätze sind JSON; paging:maxPages (100), timeout und maxBytes gelten für alle Seiten zusammen.

08Drucken

Drucker werden mit Namen konfiguriert; eine Anfrage nennt einen Drucker, nie eine Adresse – der Server ist kein offenes TCP-Relay. Etiketten gehen über Raw-TCP (Port 9100) hinaus und werden mit der dpi des Druckers erzeugt.

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
  • Eine Warteschlange je Drucker: Aufträge werden nacheinander gesendet (queueLimit, 100 wartend; mehr: 503).
  • Kein Doppeldruck: eine fehlgeschlagene Verbindung wird (retries, retryDelay verdoppelt) nur vor dem ersten Byte wiederholt. Bricht die Verbindung beim Senden ab – oder liest der Drucker länger als sendTimeout nicht (Papier leer, Puffer voll) –, scheitert der Auftrag und meldet die gesendeten Bytes; er wird nicht wiederholt.
  • Printer Twin: vor dem Senden prüft der Printer Twin die ersten printing:twinSampleLabels Etiketten gegen das Profil (Modell, dpi, Optionen). warn listet die Befunde im Job-Status, block stoppt den Auftrag bei Fehlern (422, nichts gesendet), off lässt die Prüfung aus; ?twin= überschreibt das je Anfrage.
  • checkStatus: ~HS vor jedem Auftrag; ein Drucker, der nicht bereit ist (Papier oder Farbband leer, Kopf offen, pausiert …), gilt als nicht erreichbar: nichts wird gesendet, der Versuch wird wiederholt.
  • Kopien: ?copies=n druckt jedes Etikett n-mal (^PQ), höchstens printing:maxCopies; ein Kopienfeld einer Druckauftrag-Vorlage wird multipliziert.
  • Status: GET /v1/printers/{name}/status fragt den Drucker live; GET /v1/printers zeigt die Warteschlangen; Metriken je Drucker (Metriken).
# 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

Schriften: Grafik oder Druckerspeicher

Der Server verwendet keine Systemschriften – weder für die ZPL noch für Vorschauen, Vorschau und Druck sind also auf jedem Host gleich. Eine TrueType-/OpenType-Schrift, die ein Text- oder Tabellen-Element nennt (alles außer den Ein-Zeichen-Schriften des Druckers wie 0 oder A), muss in der Vorlage eingebettet sein (fonts des Labels), als Datei im eigenen Ordner der Vorlage oder dessen Unterordner fonts/ liegen oder in einem Ordner aus output:fontFolders – ohne Beachtung der Groß-/Kleinschreibung, .TTF/.OTF wird ergänzt, wenn der Name keine Endung hat. Sonst antwortet die Anfrage mit 422 „Font not available“ samt Schrift- und Elementname; eine Schrift wird nie stillschweigend ersetzt.

fontsWas der Drucker bekommtFür
graphic (Standard)Text in TrueType-Schriften als Grafik (^GF), genau wie in der Vorschau; kein Schrift-Downloadjeden ZPL-Drucker – auch Drucker anderer Hersteller mit ZPL-Emulation
printerdie Schrift wird mit dem ersten Etikett in den Druckerspeicher geladen (~DY), der Drucker zeichnet den Text (^A@)Drucker, die TrueType-Downloads annehmen – für maximale Performance

Einstellbar für alle Anfragen (output:fonts), je Drucker (printers:<name>:fonts) oder je Anfrage (?fonts=graphic|printer, "fonts" in einem Job-Dokument); die Anfrage gewinnt vor dem Drucker, der Drucker vor output:fonts. Unverändert gesendete ZPL (/v1/print ohne Vorlage, "zpl"-Jobs) wird nicht verändert.

Der Druckerspeicher ist optional – nur für maximale Performance und nur auf Druckern, die TrueType-Downloads (~DY) unterstützen. Viele Drucker mit ZPL-Emulation können das (noch) nicht. Eine Schriftdatei ist außerdem groß (eine vollständige TrueType-Schrift kann mehrere Megabyte je Auftrag ausmachen). Im Zweifel als Grafik drucken. Im SDK ist dieselbe Wahl PrintAsGraphic und Label.DownloadFonts.

09Ablage, Jobs, Webhooks, Batches

?store=, ?webhook=, ?print= und ?async=true (oder Prefer: respond-async) wirken auf POST /v1/labels/…, POST /v1/print/… und POST /v1/batch. Ohne async wartet die Anfrage auf den Job und antwortet mit seinem Status (200, oder ein Problem, dessen instance die Status-URL ist); ein Client, der vor Beginn des Sendens abbricht, bricht den Job ab, ein begonnener Druck wird zu Ende gesendet. Mit async lautet die Antwort 202 mit Location: /v1/jobs/{id}.

Ablage

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-Ablagen nehmen Einstellungen und Anmeldung der Vorlagenquelle desselben Typs.
  • Jede Ausgabe bekommt ihren SHA-256 daneben als <name>.sha256 im sha256sum-Format (sha256sum -c prüft sie); in der Cloud zusätzlich als Metadatum sha256.
  • Leser sehen nie ein halb geschriebenes Objekt: Volumes schreiben in eine temporäre Datei und benennen sie um, Cloud-Ablagen schreiben in einer Anfrage.
  • Der Job-Status nennt Ablage, Schlüssel und Ort (file:///out/…, s3://bucket/…).
$ cat out/2026-10-25/b4b21ff0c9ab4189b3b19960eec4b9d2.zpl.sha256
9abb10f7b11b90aa349f982003246b3145e4409bad92e8c10c6488016c5df3fa  b4b21ff0c9ab4189b3b19960eec4b9d2.zpl

Asynchrone Jobs

Modus (/ready)KonfigurationVerhalten
memorykein jobs:sinkStatus und Ergebnisse im Speicher und im lokalen Spool-Ordner – nur eine Instanz; ein Neustart verliert sie (Warnung beim Start)
storagejobs:sinkStatus jobs/{id}/status.json und Ergebnis jobs/{id}/result.{ext} in der Ablage – jede Instanz kann für jeden Job antworten
storage+redisjobs:sink + jobs:redisder Status zusätzlich in Redis für schnelles Abfragen; maßgeblich bleibt die Ablage (ein Status, den Redis während eines Ausfalls verpasst hat, wird repariert)
  • Jede Instanz führt höchstens jobs:workers Jobs gleichzeitig aus und hält jobs:queueCapacity weitere in der Warteschlange (darüber 429). Ein Job wartet auf einen freien Render-Slot, statt zu scheitern.
  • Der Request-Body wird in den Spool-Ordner kopiert (jobs:spoolPath, Standard der Temp-Ordner – bei schreibgeschütztem Root-Dateisystem ein beschreibbares /tmp einhängen).
  • Der Fortschritt wird alle 2 s aktualisiert, ein Lebenszeichen alle 30 s; ein wartender oder laufender Job ohne Lebenszeichen seit 2 Minuten gilt als verloren (gescheitert, 503 „Job lost“) – reichen Sie ihn erneut ein.
  • DELETE /v1/jobs/{id} bricht einen Job ab, auch einen, der auf einer anderen Instanz läuft (über eine Abbruch-Markierung in der Ablage). Beim Herunterfahren dürfen Aufträge, die gerade an einen Drucker gehen, innerhalb der Shutdown-Zeit fertig senden.
  • Status und Ergebnis verfallen nach jobs:ttl (24 h); ein regelmäßiges Aufräumen löscht sie (Redis-Schlüssel verfallen selbst). Eine Lifecycle-Regel im Bucket ist eine gute Ergänzung.

Webhooks

Wenn ein Job endet, sendet der Server ein JSON-Dokument per POST an den benannten Empfänger: Ereignis job.succeeded, job.failed oder job.canceled, Job-ID, Status, Vorlage und Version, Format, Anzahlen, Bytes, SHA-256, Ablageort, Ergebnis-URL, Druckergebnis, Fehler und Zeiten.

{ "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" }
HeaderInhalt
X-ZplCore-Eventjob.succeeded …
X-ZplCore-DeliveryKennung der Zustellung – bei jedem Versuch gleich (zum Entdoppeln)
X-ZplCore-TimestampUnix-Sekunden des Versuchs
X-ZplCore-Signaturesha256=<hex> – HMAC-SHA256 von "<timestamp>.<body>" mit dem Schlüssel des Webhooks
  • Wiederholung bei Netzfehlern, Zeitüberschreitung, 408, 429 und 5xx mit verdoppelter Wartezeit plus Zufallsanteil; Retry-After wird beachtet (höchstens 5 Minuten). Andere Antworten und Weiterleitungen beenden die Zustellung. Das Ergebnis steht im Job-Status (webhook.state pending/delivered/failed, Versuche, letzter Status).
  • Outbox: mit jobs:sink werden die Zustellungen in der Ablage gehalten (webhooks/{id}/…), bis sie enden – Wiederholungen überstehen einen Neustart, und genau eine Instanz macht jeden Versuch. Ohne Ablage liegen sie im Speicher.
  • Ziele nur aus der Konfiguration – keine URLs aus Anfragen; nur https (außer mit allowHttp), private Netze nur mit allowPrivateNetworks oder allowedNetworks.

Die Signatur prüfen beim Empfänger – über die rohen Bytes des Bodys, vor dem Parsen, in fester Zeit, mit einem Zeitfenster gegen Wiederholungen:

// 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 erzeugt mehrere Vorlagen mit eigenen Datensätzen in einer Anfrage – z. B. die Versandetiketten aller Versender einer Tour. Jeder Teil ist ein eigener Auftrag (Start- und Endetikett, Zähler, Stored Formats). JSON-Bodys werden vor dem ersten Byte vollständig geprüft; NDJSON wird Teil für Teil gelesen, ein Fehler mittendrin bricht die Verbindung ab. Mit store, webhook, print oder async läuft der Batch als ein Job (ZIP beim Drucken: 406 – Drucken sendet ZPL). Einzelheiten unter Endpunkte.

# 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

10Sicherheit

  • Anmeldung Pflicht: kein Standardmodus; jwt nur mit Audience; API-Schlüssel mindestens 16 Zeichen, als Hash in fester Zeit verglichen (Anmeldung).
  • Grenzen je Anfrage: Body-Größe, Datensätze, Etiketten von Bildausgaben, Vorlagengröße, Etikettenseite, Feldlänge, Render-Slots mit Warteschlange und Wartezeit, Zeitlimit und optionales Rate-Limit (limits). Ein Client, der die Verbindung trennt, oder das Zeitlimit stoppt Erzeugung und Rendern; der Render-Slot wird frei.
  • Bilder: jeder Auftrag läuft in einem ZplLimits-Scope. Bilddateien (sourceFile oder ein Pfad in einem Datensatz) werden nur unterhalb des Ordners der eigenen Volume-Quelle der Vorlage gelesen; entfernte und Inline-Vorlagen lesen keine lokalen Dateien. Bilder werden vor dem Dekodieren gegen maxImagePixels, maxImageBytes und maxSvgBytes geprüft (413).
  • Ausgehende Aufrufe (HTTP-Vorlagen, REST-Abfragen, Webhooks) gehen nur an konfigurierte Ziele – nie an eine Adresse aus einer Anfrage. Jede aufgelöste Adresse wird vor dem Verbinden geprüft und genau diese Adresse verwendet (kein DNS-Rebinding). Gesperrt, sofern nicht erlaubt: Loopback, private Netze (RFC 1918, Carrier-Grade-NAT, IPv6 Unique Local), Link-Local mit der Cloud-Metadatenadresse 169.254.169.254, die Azure-Plattformadresse 168.63.129.16, reservierte, Benchmark- und Dokumentationsbereiche; IPv6-Formen mit eingebetteter IPv4-Adresse werden nach dieser Adresse beurteilt. Unspezifizierte und Multicast-Adressen sind nie erlaubt. allowedNetworks (CIDR) öffnet einzelne Netze, allowPrivateNetworks (Webhooks) private Netze und Loopback. Kein Proxy, keine Zugangsdaten in URLs, Weiterleitungen nur zu erlaubten Hosts und nie von https zu http.
  • Datenquellen sind nur lesend (siehe SQL); kein Ad-hoc-SQL, keine freien URLs.
  • Drucker nur über Namen, kein TCP-Relay.
  • Secrets nur aus Dateien oder der Umgebung; nie in Logs, Antworten oder Katalogen (Secrets). Fehler der Engine erreichen den Client mit neutralen Texten; 500-Antworten tragen nur eine traceId.
  • Anonyme Probes verraten nichts: /ready ohne Anmeldung antwortet nur mit dem Status; /metrics ist standardmäßig aus.
  • Antworten: X-Content-Type-Options: nosniff auf jeder Antwort, SVG mit Content-Security-Policy: sandbox; Request-Logs ohne Query-String.
  • Container: nicht root (UID 1654), keine Shell, /templates schreibgeschützt; betreiben Sie ihn mit schreibgeschütztem Root-Dateisystem und ohne 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

11Betrieb

  • /health (Liveness) antwortet ok, solange der Prozess läuft.
  • /ready (Readiness) antwortet mit 200, sobald der Renderer geladen ist, jede Vorlagenquelle einmal gelesen wurde und jede Datenverbindung mit required: true antwortet; sonst 503. Der Testmodus ist ebenfalls bereit – er fügt nur das Wasserzeichen hinzu. Angemeldete Aufrufer bekommen die Details (Beispiel): Version, Build-Datum, Lizenzzustand, Vorlagen, problems (Quellenfehler, Lizenz-Neuladen), Jobs, Drucker, Ablagen und Webhooks.

Metriken und Logs

/metrics ist standardmäßig aus. metrics:enabled: true plus entweder metrics:port (ein eigener Port wie 9464, nur dort beantwortet und ohne weitere Prüfung – den Port im Cluster halten) oder auf dem Hauptport der Metrik-Schlüssel ZPLCORE__METRICS__APIKEY im API-Key-Header bzw. eine Anfrage, die die Anmeldung besteht.

MetrikTypLabels
zplcore_server_requests_totalCounterformat, status
zplcore_server_labels_total, zplcore_server_output_bytes_totalCounterformat
zplcore_server_request_duration_secondsHistogrammformat
zplcore_server_rejected_totalCounterreason
zplcore_server_renders_busy, _renders_queued, _renders_capacityGauge
zplcore_server_templates, zplcore_server_template_errorsGauge
zplcore_server_license_watermarkedGauge1 = Testmodus
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

Dazu Prozess- und Runtime-Werte (process_working_set_bytes, process_uptime_seconds, dotnet_gc_heap_bytes …).

  • OpenTelemetry: mit OTEL_EXPORTER_OTLP_ENDPOINT exportiert der Server Metriken (Meter ZplCore.Server sowie die Meter von ASP.NET Core und der Runtime), Traces (ohne Query-Strings, ohne Probes) und Logs über OTLP; das Image setzt OTEL_SERVICE_NAME=zplcore-server.
  • Logs: JSON-Zeilen auf der Standardausgabe, Zeitstempel in UTC; eine Zeile je Anfrage mit Methode, Pfad, Status und Dauer – nie der Query-String (er kann Parameter tragen). Die Startzeile nennt Version, Startzeit, Anzahl der Vorlagen, Render-Slots und Lizenzzustand.

Kubernetes und Skalierung

Der Server hält keinen eigenen Zustand: mehrere Replikate hinter einem Load Balancer brauchen keine Sticky Sessions. Für asynchrone Jobs über Replikate hinweg jobs:sink (und optional Redis) konfigurieren; ohne sie kennt jedes Replikat nur seine eigenen Jobs. Skalieren nach CPU oder nach den Metriken zplcore_server_renders_queued und 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: {} }

Ein Helm-Chart für zplCore Server ist geplant. Vorlagen aus Git: git-sync als Init-Container (--one-time) und Sidecar, die sich ein emptyDir-Volume mit dem Server teilen (Git).

NativeAOT, -full und Plattformen

NativeAOT (Standard-Image)-full (JIT)
Start, Größeein natives Programm, startet schnell, kleineres Image.NET-Runtime, langsamerer Start
Oracle–mit eigenem Treiber
SQL ServerSQL-Loginsauch Entra ID / Managed Identity, integrierte Sicherheit, Always Encrypted
ZPL-Erzeugung von Datensätzen im Speicherauf allen Kernen (output:generationThreads 0)seriell (byte-gleiche Ausgabe)
Plattformenlinux/amd64, linux/arm64linux/amd64, linux/arm64; linux/arm/v7 als -arm32v7

Beide Varianten erzeugen dieselbe Ausgabe. Die Images basieren auf Ubuntu (chiseled, glibc) und enthalten die nativen Bibliotheken, die der Renderer braucht. 32-Bit-ARM, z. B. ein Raspberry Pi mit 32-Bit-Betriebssystem, braucht das Image -arm32v7; ein 64-Bit-Raspberry-Pi-OS nutzt das Standard-Image (linux/arm64). Windows und macOS führen die Linux-Images mit Docker Desktop aus.

12Fehlerbehebung

Der Container beendet sich sofort (Exit-Code 2)

Konfigurationsfehler beenden den Start mit Exit-Code 2 und einer Zeile auf der Fehlerausgabe (kein Stacktrace):

Meldung (gekürzt)Abhilfe
auth:mode is not set. Set the environment variable ZPLCORE__AUTH__MODE …-e ZPLCORE__AUTH__MODE=apikey (mit Schlüssel), jwt oder – lokal – none
auth:mode 'apikey' needs at least one key … / API keys must be at least 16 characters long.ZPLCORE__AUTH__APIKEYS__0 oder auth:apiKeysFile mit einem ausreichend langen Schlüssel
auth:mode 'jwt' needs auth:audience (ZPLCORE__AUTH__AUDIENCE) …die Audience setzen, für die Ihr Identity Provider die Tokens ausstellt
…zplcore.yaml: secrets must not be stored in the configuration file (license:key). Use a secret file … or environment variables …den Wert in einen Schlüssel …File oder eine Variable ZPLCORE__… verlegen
Configuration file '…' does not exist.--config, ZPLCORE_CONFIG und die Einhängung prüfen
data:connections:<name>:provider 'oracle' needs the Oracle driver …das -full-Image mit dem Treiber bauen (Oracle)
eine Meldung, die einen anderen Schlüssel nennt (limits:…, printers:<name>:…, sinks:…, ein unbekanntes Twin-Profil, eine SQL-Anweisung, die nicht nur liest …)die Meldung nennt den Schlüssel und die erlaubten Werte

Typische Antworten

  • 401 – Header fehlt oder ist falsch: X-Api-Key (bzw. auth:apiKeyHeader), oder das JWT hat die falsche Audience, den falschen Issuer oder die falsche Rolle.
  • 406 – Accept nennt einen nicht unterstützten Typ, oder PNG/SVG für mehrere Etiketten (application/zip oder application/pdf verwenden).
  • 413 – Bildausgaben über limits:maxRenderLabels (für große Aufträge ZPL verwenden), ein Bild über den Bildgrenzen oder ein Body über limits:maxRequestBodyBytes.
  • 422 – title und detail lesen: „Unknown field“ (den Namen als Auftragsvariable mitschicken), „Font not available“, „Label generation failed“ (ein Wert oder Ausdruck – das Log hat die Details unter der traceId), „Invalid template“, „Printer twin check failed“.
  • 429 – alle Render-Slots belegt: limits:maxConcurrentRenders/maxQueuedRenders erhöhen oder Replikate hinzufügen; ohne Body: das Rate-Limit.
  • 502 – eine Vorlagenquelle, Datenbank, ein REST-Dienst oder Drucker ist nicht erreichbar; detail nennt, welcher (nie Zugangsdaten).
  • 504 – limits:requestTimeout, der timeout einer Abfrage oder ein Drucker, der ~HS nicht beantwortet.

Vorlagen werden nicht geladen

  • GET /v1/templates listet Dateien, die nicht geladen werden konnten, mit Grund (errors) und jede Quelle mit ihrem letzten Fehler; /ready (angemeldet) zeigt problems.
  • Nur *.json-Dateien mit einem gültigen Label, Druckauftrag oder Designer-Dokument; unbekannte Eigenschaften sind Fehler (z. B. ein Tippfehler oder eine Eigenschaft eines neueren Schemas).
  • Das Volume muss für UID 1654 lesbar sein; Links dürfen den Ordner nicht verlassen; Ordner, die mit . beginnen, werden übersprungen.
  • Object Stores: stimmt prefix, und liegen darunter mehr als maxObjects Objekte? git-sync: ist poll kurz und der Pfad /git/templates?

„Font not available“

Der Server verwendet nie Systemschriften. Betten Sie die Schrift in die Vorlage ein, legen Sie die Datei neben die Vorlage (oder in deren Ordner fonts/), oder hängen Sie einen Ordner ein und tragen Sie ihn in output:fontFolders ein. Der Name in der Vorlage (E:ARIAL.TTF) wird ohne Laufwerk und ohne Beachtung der Groß-/Kleinschreibung gesucht.

Der Drucker druckt nicht

  • GET /v1/printers/{name}/status: ist der Drucker erreichbar und bereit (Papier, Farbband, Kopf, Pause)?
  • Der Job-Status (print.state, print.error, print.twin.findings): blocked = der Printer Twin hat mit twin: block Fehler gefunden; failed, nachdem Bytes gesendet wurden = die Verbindung ist abgebrochen, nichts wurde wiederholt.
  • Text in TrueType-Schriften fehlt oder ist falsch auf einem Drucker mit ZPL-Emulation: fonts: graphic verwenden (Schriften).
  • Falsche Größe oder Position: dpi und model des Druckers in zplcore.yaml müssen zum Gerät passen.