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.
01Überblick
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
- 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äuft | in Ihrer .NET-Anwendung | als eigener Dienst, per HTTP aus jeder Sprache aufgerufen |
| Vorlagen | lädt Ihr Code | auf dem Server: Volume, Git, S3, Azure Blob, GCS, HTTP – neu geladen und versioniert |
| Daten | Ihre Objekte, EF Core, LiteDB … | JSON, NDJSON, CSV oder benannte SQL-/REST-Abfragen – ohne Code |
| Ihr Code (TCP, USB, Spooler, CUPS) | konfigurierte Netzwerkdrucker (TCP 9100) mit Warteschlange und Printer Twin | |
| Passt für | .NET-Teams, die volle Kontrolle wollen | Teams ohne .NET, zentrale Etikettendienste, Kubernetes |
02Schnellstart
Images und Tags
| Tag (geplant) | Build | Plattformen | Wann |
|---|---|---|---|
zplcore/server:1.0.0 | NativeAOT – ein Programm, startet schnell, klein | linux/amd64, linux/arm64 | der Standard |
zplcore/server:1.0.0-full | JIT (.NET-Runtime) | linux/amd64, linux/arm64 | eigener Oracle-Treiber; SQL Server mit Entra ID, integrierter Sicherheit oder Always Encrypted |
zplcore/server:1.0.0-arm32v7 | JIT | linux/arm/v7 | 32-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:
ZPLCORE__LICENSE__KEY– der Schlüssel selbst als Umgebungsvariable,- 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, - die Quellen des SDK:
ZPLCORE_LICENSE,ZPLCORE_LICENSE_FILE, eine Dateizplcore.licenseneben 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:
--config <datei>auf der Kommandozeile,- die Umgebungsvariable
ZPLCORE_CONFIG, /config/zplcore.yaml(dort die eigene einhängen),- die
zplcore.yamlneben 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üssel | Standard | Bedeutung |
|---|---|---|
mode | Pflicht | apikey, jwt (auch oidc) oder none |
apiKeyHeader | X-Api-Key | Header mit dem API-Schlüssel |
apiKeys | nur Umgebung: ZPLCORE__AUTH__APIKEYS__0, __1 …; je mindestens 16 Zeichen | |
apiKeysFile | Datei mit einem Schlüssel je Zeile, # leitet einen Kommentar ein | |
authority | OIDC-Issuer (Modus jwt, Pflicht) | |
audience | erwartete Audience des Tokens (Modus jwt, Pflicht) | |
requiredRole | Rolle, die das Token tragen muss (Claim roles oder role) | |
requireHttpsMetadata | true | OIDC-Metadaten nur über https |
license
| Schlüssel | Standard | Bedeutung |
|---|---|---|
file | /run/secrets/zplcore-license | Datei mit dem Lizenzschlüssel, alle 30 s geprüft |
key | nur Umgebung: ZPLCORE__LICENSE__KEY |
templates
| Schlüssel | Standard | Bedeutung |
|---|---|---|
sources | Image: Volume /templates | Liste der Vorlagenquellen in Prioritätsreihenfolge (Schlüssel unten) |
keepVersions | 10 | frühere Versionen, die je Vorlage gehalten werden (abrufbar als pfad@version) |
inline:enabled | false | schaltet POST /v1/render ein (Vorlage im Request) |
Schlüssel einer Quelle (templates:sources:N):
| Schlüssel | Standard | Typen | Bedeutung |
|---|---|---|---|
name | alle | Name der Quelle (Buchstaben, Ziffern, ., _, -); name:pfad fragt diese Quelle | |
type | volume | alle | volume, s3, azureblob, gcs, http |
poll | Volume 10 s, sonst 30 s | alle | Abfrage-Intervall |
timeout | 00:00:30 | entfernt | längste Dauer einer Anfrage an die Quelle |
path | volume | Ordner, schreibgeschützt eingehängt | |
bucket / container | s3, gcs / azureblob | Bucket bzw. Container | |
prefix | s3, azureblob, gcs | Schlüssel-Präfix, z. B. prod/: prod/shipping/x.json → shipping/x | |
serviceUrl | der Cloud-Dienst | s3, azureblob, gcs | S3-kompatibler Endpunkt (MinIO), Azure-Konto-URL, anderer GCS-Endpunkt |
maxObjects | 10000 | s3, azureblob, gcs | höchstens so viele Objekte je Auflistung (0 = keine Grenze) |
region | AWS_REGION | s3 | AWS-Region; mit serviceUrl die Signatur-Region (Standard us-east-1) |
pathStyle | false | s3 | Path-Style-Anfragen (MinIO und die meisten S3-kompatiblen Speicher) |
accessKeyId / accessKeyIdFile | s3 | Access Key ID; ohne Schlüssel die AWS-Standardkette | |
secretAccessKey / secretAccessKeyFile | s3 | Secret Access Key (Wert nur Umgebung) | |
sessionToken / sessionTokenFile | s3 | Session Token temporärer Zugangsdaten (Wert nur Umgebung) | |
connectionString / connectionStringFile | azureblob | Storage-Verbindungszeichenfolge (Wert nur Umgebung) | |
sasToken / sasTokenFile | azureblob | SAS-Token für serviceUrl (Wert nur Umgebung) | |
identity | azureblob | managed (Managed Identity) oder workload (AKS Workload Identity), mit serviceUrl | |
clientId / tenantId | AZURE_CLIENT_ID / AZURE_TENANT_ID | azureblob | benutzerzugewiesene Identität bzw. Workload Identity |
credentialsFile | GOOGLE_APPLICATION_CREDENTIALS | gcs | Dienstkonto-Schlüssel (JSON); ohne ihn der Metadatenserver |
anonymous | false | gcs | ohne Anmeldung (Emulator, öffentlicher Bucket) |
baseUrl | http | Basis-URL; shipping/x ist <baseUrl>shipping/x.json | |
allowedHosts | Host der baseUrl | http | Hosts, die die Quelle anfragen darf, Weiterleitungen eingeschlossen |
allowedNetworks | http | Netze (CIDR), die erreicht werden dürfen, obwohl privat, Loopback oder Link-Local | |
allowHttp | false | http | reines http erlauben |
bearerToken / bearerTokenFile | http | Bearer-Token (Wert nur Umgebung) | |
basicAuth / basicAuthFile | http | user:password (Wert nur Umgebung) |
limits
| Schlüssel | Standard | Bedeutung |
|---|---|---|
maxRequestBodyBytes | 268435456 (256 MiB) | größter Request-Body (413) |
maxRecordsPerRequest | 1000000 | Datensätze je Anfrage (auch alle Teile eines Batches zusammen) |
maxRenderLabels | 1000 | Etiketten je Anfrage für PNG, SVG, PDF, ZIP und JSON mit Diagnosen (413) |
maxTemplateBytes | 8388608 (8 MiB) | größte Vorlagendatei |
maxConcurrentRenders | 0 = Prozessoren | Render-Slots |
maxQueuedRenders | 64 | Anfragen, die auf einen Slot warten; mehr: 429 |
queueTimeout | 00:00:30 | längste Wartezeit auf einen Slot; dann 429 |
requestTimeout | 00:10:00 | längste Dauer einer Anfrage (504) |
rateLimitPermits | 0 = aus | Anfragen je Fenster und Client |
rateLimitWindow | 00:01:00 | Fenster des Rate-Limits |
maxLabelSide | 8000 | größte Etikettenseite in Dots (auch ^PW/^LL) |
maxFieldDataLength | 65536 | Zeichen eines Feldes |
maxImagePixels | 40000000 | größtes Bild (Pixel), das ein Bild, QR-Logo oder Barcode-Art dekodiert; 0 = keine Grenze |
maxImageBytes | 10485760 (10 MiB) | größtes Rasterbild einer Vorlage oder eines Datensatzes; 0 = keine Grenze |
maxSvgBytes | 2097152 (2 MiB) | größtes SVG einer Vorlage oder eines Datensatzes; 0 = keine Grenze |
output
| Schlüssel | Standard | Bedeutung |
|---|---|---|
defaultDpi | 203 | Auflösung, wenn die Anfrage keine nennt (100–1200) |
storedFormatMinRecords | 1000 | ab so vielen Datensätzen (Anzahl vorab bekannt) wird das Datensatz-Etikett einmal im Drucker abgelegt (^DF/^XF, druckt gleich); 0 = nie |
storedFormatName | R:ZPLCORE.ZPL | Name dieses Stored Formats |
fontFolders | Ordner mit TTF-/OTF-Dateien, die Vorlagen nennen dürfen (Schriften) | |
fonts | graphic | graphic oder printer: wie Text in TrueType-Schriften zum Drucker kommt (Schriften) |
generationThreads | 0 = automatisch | Threads, die die ZPL einer Anfrage mit Datensätzen im Speicher erzeugen: Prozessoren im NativeAOT-Image, 1 unter JIT; 1 = seriell |
docs, metrics
| Schlüssel | Standard | Bedeutung |
|---|---|---|
docs:enabled | true | /docs (Scalar) und /openapi/v1.json |
metrics:enabled | false | /metrics im Prometheus-Format |
metrics:port | eigener Port für /metrics (z. B. 9464), ohne weitere Prüfung | |
metrics:apiKey | nur Umgebung: Schlüssel, der /metrics auf dem Hauptport öffnet |
data – benannte Abfragen
data:connections:<name>:
| Schlüssel | Standard | Bedeutung |
|---|---|---|
provider | sqlite, postgresql, sqlserver, mysql (auch MariaDB), oracle (-full-Image mit Ihrem Treiber) oder rest | |
required | false | /ready antwortet 503, solange die Verbindung nicht erreichbar ist |
connectionString / connectionStringFile | Datenbanken (Wert nur Umgebung) | |
baseUrl | REST: Basisadresse; Abfragepfade sind relativ dazu | |
allowedHosts | Host der baseUrl | REST: weitere Hosts für Seiten-Links und Weiterleitungen |
allowedNetworks | REST: Netze (CIDR), die erreicht werden dürfen, obwohl privat, Loopback oder Link-Local | |
allowHttp | false | REST: reines http erlauben |
auth:type | none | REST: bearer, basic, header |
auth:token / auth:tokenFile | Bearer-Token (Wert nur Umgebung) | |
auth:username, auth:password / auth:passwordFile | Basic (Passwort nur Umgebung) | |
auth:header, auth:value / auth:valueFile | eigener Header, z. B. X-Api-Key (Wert nur Umgebung) |
data:queries:<name>:
| Schlüssel | Standard | Bedeutung |
|---|---|---|
connection | Name der Verbindung | |
sql | Datenbanken: eine SELECT-Anweisung (oder WITH … SELECT); Parameter @name (Oracle :name) | |
countSql | Datenbanken, optional: die Zeilenzahl von sql (gleiche Parameter) | |
path | REST: Pfad und Query relativ zur baseUrl; {name} = URL-kodierter Parameter | |
items | $ | REST: JSON-Pfad zum Array der Datensätze, z. B. $.items |
paging:next | REST: link (Link-Header rel="next") oder ein JSON-Pfad wie $.next | |
paging:maxPages | 100 | REST: höchstens so viele Seiten je Aufruf |
parameters:<name>:type | string | string, int, long, decimal, double, bool, date (yyyy-MM-dd), datetime (ISO 8601) |
parameters:<name>:required | false | der Parameter muss angegeben werden |
parameters:<name>:pattern | Zeichenketten: regulärer Ausdruck für den ganzen Wert | |
maxRows | 0 = limits:maxRecordsPerRequest | höchstens so viele Zeilen je Aufruf; mehr beenden die Anfrage mit einem Fehler |
timeout | 00:00:30 | längste Dauer eines Aufrufs (alle Zeilen, alle Seiten) |
maxBytes | 0 = limits:maxRequestBodyBytes | höchstens so viele Daten je Aufruf |
printers, printing
printers:<name>:
| Schlüssel | Standard | Bedeutung |
|---|---|---|
host | Hostname oder IP-Adresse des Druckers | |
port | 9100 | Raw-TCP-Port |
model | Modell, z. B. ZD421; mit dpi wählt es das Printer-Twin-Profil | |
profile | aus model + dpi, sonst generisch | Printer-Twin-Profil, z. B. ZD421d-203 |
dpi | Profil, sonst output:defaultDpi | Auflösung, mit der die Etiketten erzeugt werden |
options | eingebaute Optionen für den Twin: cutter, peeler, rewinder, rfid, linerless, applicator, realtimeclock | |
twin | warn | off, warn oder block |
fonts | output:fonts | graphic oder printer für diesen Drucker |
timeout | 00:00:10 | Verbindungsaufbau und ~HS |
sendTimeout | 00:01:00 | längste Zeit ohne Fortschritt beim Senden |
retries | 2 | weitere Versuche – nur vor dem ersten Byte |
retryDelay | 00:00:01 | Wartezeit vor dem ersten erneuten Versuch, je weiterem verdoppelt |
queueLimit | 100 | Aufträge, die auf diesen Drucker warten; mehr: 503 |
checkStatus | false | ~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>: | Standard | Bedeutung |
|---|---|---|
url | Empfänger (https) | |
secretFile / secret | Signaturschlüssel, mindestens 16 Zeichen (Wert nur Umgebung: ZPLCORE__WEBHOOKS__<NAME>__SECRET) | |
timeout | 00:00:10 | je Versuch |
retries | 5 | weitere Versuche nach Netzfehlern, Zeitüberschreitung, 408, 429, 5xx |
retryDelay | 00:00:02 | erste Wartezeit, jedes Mal verdoppelt (höchstens 5 Minuten) |
allowHttp | false | reines http erlauben |
allowPrivateNetworks | false | private Netze und Loopback erlauben |
allowedNetworks | Netze (CIDR), die erlaubt sind, obwohl sonst gesperrt (auch Link-Local) |
jobs, batch
| Schlüssel | Standard | Bedeutung |
|---|---|---|
jobs:enabled | true | asynchrone Jobs und /v1/jobs |
jobs:workers | 0 = Prozessoren | gleichzeitig laufende Jobs auf dieser Instanz |
jobs:queueCapacity | 100 | wartende Jobs auf dieser Instanz; mehr: 429 |
jobs:sink | Ablage für Job-Status und Ergebnisse (jobs/{id}/…); ohne sie: Speicher, eine Instanz | |
jobs:ttl | 24:00:00 | wie lange Status und Ergebnis erhalten bleiben |
jobs:cleanupInterval | 00:10:00 | wie oft abgelaufene Jobs gelöscht werden |
jobs:spoolPath | Temp-Ordner | lokaler Ordner für Request-Bodys und Ausgaben, solange ein Job läuft |
jobs:redis:connectionString / connectionStringFile | Job-Status zusätzlich in Redis (braucht jobs:sink; Wert nur Umgebung) | |
jobs:redis:keyPrefix | zplcore:jobs: | Präfix der Redis-Schlüssel |
batch:maxParts | 1000 | Teile 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
| Modus | Wie | Für |
|---|---|---|
apikey | Schlü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 verglichen | Aufrufe 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:requiredRole | Entra ID, Keycloak, Auth0 … |
none | keine Prüfung – jeder, der den Server erreicht, darf ihn aufrufen; Warnung im Log | lokale 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.
| Methode | Pfad | Zweck |
|---|---|---|
| POST | /v1/labels/{template} | eine Vorlage mit Datensätzen füllen, die Etiketten zurückgeben (oder drucken, ablegen, melden) |
| POST | /v1/render | dasselbe mit der Vorlage im Request (standardmäßig aus) |
| GET | /v1/templates | Katalog: Vorlagen, Versionen, Fehler, Quellen |
| GET | /v1/templates/{path}, …/preview | eine Vorlage als JSON; PNG ihres ersten Etiketts |
| GET | /v1/queries | benannte Abfragen mit ihren Parametern |
| POST | /v1/print/{printer} | ZPL drucken oder eine Vorlage mit Datensätzen |
| GET | /v1/printers, /v1/printers/{name}/status | Drucker mit Warteschlange; Live-Status (~HS) |
| POST · GET · DELETE | /v1/jobs, /v1/jobs/{id}, /v1/jobs/{id}/result | asynchrone Jobs |
| POST | /v1/batch | mehrere Vorlagen und Datensatzgruppen in einer Anfrage |
| GET | /health, /ready, /metrics, /docs, /openapi/v1.json | Betrieb |
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:
Accept | Antwort |
|---|---|
application/vnd.zpl (Standard; auch text/plain, */*) | ZPL, Datensatz für Datensatz als Strom |
image/png, image/svg+xml | genau ein Etikett; mehrere → 406 |
application/pdf | ein PDF, eine Seite je Etikett |
application/zip | ein Bild je Etikett (label00001.png …), während des Renderns geschrieben |
application/json | Bericht { template, version, records, labels, zplBytes, sha256, license, diagnostics?, zpl? } |
| Query-Parameter | Bedeutung |
|---|---|
dpi | 100–1200, Standard output:defaultDpi |
version | Vorlagenversion (Inhalts-Hash oder die Versionskennung der Quelle) |
format | ZIP-Einträge: png (Standard) oder svg |
diagnostics | true: der JSON-Bericht rendert die Etiketten und listet die Diagnosen des Renderers |
zpl | true: der JSON-Bericht enthält die ZPL |
copies | druckt jedes Etikett so oft (^PQ), 1 bis printing:maxCopies |
fonts | graphic oder printer (Schriften) |
delimiter | CSV-Trennzeichen: , ; tab | (Standard: erkannt) |
query + ihre Parameter | Datensätze aus einer benannten Abfrage, z. B. ?query=open-shipments&country=AT |
print, twin | an einen konfigurierten Drucker senden; Twin-Prüfung off/warn/block |
store, webhook | in eine Ablage schreiben; einen Webhook benachrichtigen |
async | true (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
| Anfrage | Antwort |
|---|---|
POST /v1/jobs | 202, 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}/result | die 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:
| Eigenschaft | Bedeutung |
|---|---|
template, version | Vorlagenpfad und Version |
records oder query | Datensätze (Array oder ein Objekt) oder { "name": …, "parameters": { … } } |
variables | Auftragsvariablen (Start- und Endetikett) |
zpl | ZPL, die unverändert gedruckt oder abgelegt wird (statt einer Vorlage) |
parts | ein Batch: [ { "template", "records", "variables", "version", "copies" }, … ] |
format, zipEntries | zpl (Standard), png, svg, pdf, zip, json; ZIP-Einträge png/svg (Batch zpl/pdf) |
dpi, copies, fonts, diagnostics, includeZpl | wie die Query-Parameter von /v1/labels |
print, twin, store, webhook | Drucker, 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
| Pfad | Antwort |
|---|---|
GET /health | ok – Liveness, ohne Anmeldung |
GET /ready | 200 {"status":"ready"} oder 503 {"status":"not-ready"}; mit Anmeldung die Details unten |
GET /metrics | Prometheus-Textformat; standardmäßig aus (Metriken) |
GET /docs | interaktive API-Referenz (Scalar), vom Server selbst ausgeliefert – kein CDN |
GET /openapi/v1.json | OpenAPI-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" }
| Status | Typischer Titel | Ursache |
|---|---|---|
| 400 | Invalid records · Invalid dpi · Invalid copies · Invalid query parameters · Local files not allowed | JSON/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 |
| 404 | Template not found · Query not found · Printer not found · Sink not found · Webhook not found · Job not found | unbekannter Name, unbekannte Version oder abgelaufener Job |
| 406 | Unsupported Accept header · More than one label | Accept nicht unterstützt; PNG/SVG für mehrere Etiketten; ZIP beim Drucken |
| 409 | Job not finished · Job did not succeed | Ergebnis zu früh oder von einem gescheiterten Job angefordert |
| 413 | Too many labels · Image too large | limits:maxRenderLabels; ein Bild über maxImagePixels/maxImageBytes/maxSvgBytes; Body über maxRequestBodyBytes |
| 415 | – | Content-Type nicht unterstützt |
| 422 | Invalid template · Label generation failed · Unknown field · Font not available · Too many copies · Printer twin check failed | Vorlage 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 |
| 429 | Server busy · Job queue full | alle Render-Slots belegt; Job-Warteschlange voll; Rate-Limit (ohne Body) |
| 500 | Internal error | unerwartet – Details im Log unter der traceId |
| 502 | Template source unavailable · Query failed · Printer not reachable | Quelle, Datenbank, REST-Dienst oder Drucker nicht erreichbar |
| 503 | Printer queue full · Job not accepted · Job lost | queueLimit; Job-Status nicht speicherbar; die Instanz eines Jobs ist ausgefallen |
| 504 | –, Query timeout, Printer did not answer | limits: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..dataab). - Priorität: bei mehreren Quellen gewinnt die erste in der Liste, die den Pfad hat;
name:pfadfragt 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/) oderoutput:fontFolders(Schriften).
Versionen, Cache und Neuladen
- Jede geladene Datei bekommt eine Version: die ersten 12 Hex-Ziffern ihres SHA-256 (
X-Template-Version). Die letztentemplates:keepVersions(10) Versionen bleiben im Speicher und sind alspfad@versionoder?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.
httplädt eine Vorlage beim ersten Aufruf. - Fehler: eine kaputte neue Datei ersetzt nie die laufende Version – der Fehler steht in
GET /v1/templatesund in/ready(problems)./readyantwortet 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öchstensmaxObjects(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-Type | Datensätze |
|---|---|
application/json | ein 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-ndjson | ein JSON-Objekt je Zeile, als Strom gelesen (die Anzahl ist vorab nicht bekannt) |
text/csv | Kopfzeile = 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"}}'
| Provider | Nur lesend durch | Hinweise |
|---|---|---|
postgresql | Transaktion REPEATABLE READ, READ ONLY | eingebaute skalare Typen (Text, Zahlen, bool, Datum/Zeit, uuid, bytea); andere im SQL umwandeln (col::text) |
mysql (MariaDB) | START TRANSACTION READ ONLY | AllowLoadLocalInfile und AllowUserVariables werden abgelehnt |
sqlite | Nur-Lese-Modus und query_only | Datei 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 daran | NativeAOT-Image: nur SQL-Logins; Entra ID / Managed Identity, integrierte Sicherheit und Always Encrypted brauchen -full |
oracle | SET TRANSACTION READ ONLY | nur -full mit eigenem Treiber (unten); Parameter :name |
- Beim Start geprüft: genau eine
SELECT-Anweisung (oderWITH … 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
parametersdes Bodys, werden in ihrentypeumgewandelt und vor der Abfrage gegenrequiredundpatterngeprüft (400). Die Namen der eigenen Parameter der Etiketten-Endpunkte (template,dpi,copies,print…) sind nicht erlaubt. countSqlmacht die Anzahl der Datensätze vor dem ersten Etikett bekannt: Summen auf dem Startetikett,X-Record-Count, Stored Formats, undmaxRowswird 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:/readyantwortet 503, solange die Verbindung nicht erreichbar ist (mit kurzem Zeitlimit geprüft und kurz gecacht; keine Verbindungsdetails in der Antwort).GET /v1/querieslistet 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,retryDelayverdoppelt) nur vor dem ersten Byte wiederholt. Bricht die Verbindung beim Senden ab – oder liest der Drucker länger alssendTimeoutnicht (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:twinSampleLabelsEtiketten gegen das Profil (Modell, dpi, Optionen).warnlistet die Befunde im Job-Status,blockstoppt den Auftrag bei Fehlern (422, nichts gesendet),offlässt die Prüfung aus;?twin=überschreibt das je Anfrage. checkStatus:~HSvor 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=ndruckt jedes Etikett n-mal (^PQ), höchstensprinting:maxCopies; ein Kopienfeld einer Druckauftrag-Vorlage wird multipliziert. - Status:
GET /v1/printers/{name}/statusfragt den Drucker live;GET /v1/printerszeigt 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.
fonts | Was der Drucker bekommt | Für |
|---|---|---|
graphic (Standard) | Text in TrueType-Schriften als Grafik (^GF), genau wie in der Vorschau; kein Schrift-Download | jeden ZPL-Drucker – auch Drucker anderer Hersteller mit ZPL-Emulation |
printer | die 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.
~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>.sha256imsha256sum-Format (sha256sum -cprüft sie); in der Cloud zusätzlich als Metadatumsha256. - 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) | Konfiguration | Verhalten |
|---|---|---|
memory | kein jobs:sink | Status und Ergebnisse im Speicher und im lokalen Spool-Ordner – nur eine Instanz; ein Neustart verliert sie (Warnung beim Start) |
storage | jobs:sink | Status jobs/{id}/status.json und Ergebnis jobs/{id}/result.{ext} in der Ablage – jede Instanz kann für jeden Job antworten |
storage+redis | jobs:sink + jobs:redis | der 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:workersJobs gleichzeitig aus und hältjobs:queueCapacityweitere 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/tmpeinhä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" }
| Header | Inhalt |
|---|---|
X-ZplCore-Event | job.succeeded … |
X-ZplCore-Delivery | Kennung der Zustellung – bei jedem Versuch gleich (zum Entdoppeln) |
X-ZplCore-Timestamp | Unix-Sekunden des Versuchs |
X-ZplCore-Signature | sha256=<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-Afterwird beachtet (höchstens 5 Minuten). Andere Antworten und Weiterleitungen beenden die Zustellung. Das Ergebnis steht im Job-Status (webhook.statepending/delivered/failed, Versuche, letzter Status). - Outbox: mit
jobs:sinkwerden 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 mitallowPrivateNetworksoderallowedNetworks.
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;
jwtnur 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 (sourceFileoder 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 gegenmaxImagePixels,maxImageBytesundmaxSvgBytesgeprü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:
/readyohne Anmeldung antwortet nur mit dem Status;/metricsist standardmäßig aus. - Antworten:
X-Content-Type-Options: nosniffauf jeder Antwort, SVG mitContent-Security-Policy: sandbox; Request-Logs ohne Query-String. - Container: nicht root (UID 1654), keine Shell,
/templatesschreibgeschü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) antwortetok, solange der Prozess läuft./ready(Readiness) antwortet mit 200, sobald der Renderer geladen ist, jede Vorlagenquelle einmal gelesen wurde und jede Datenverbindung mitrequired: trueantwortet; 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.
| Metrik | Typ | Labels |
|---|---|---|
zplcore_server_requests_total | Counter | format, status |
zplcore_server_labels_total, zplcore_server_output_bytes_total | Counter | format |
zplcore_server_request_duration_seconds | Histogramm | 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 = Testmodus |
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 |
Dazu Prozess- und Runtime-Werte (process_working_set_bytes, process_uptime_seconds, dotnet_gc_heap_bytes …).
- OpenTelemetry: mit
OTEL_EXPORTER_OTLP_ENDPOINTexportiert der Server Metriken (MeterZplCore.Serversowie die Meter von ASP.NET Core und der Runtime), Traces (ohne Query-Strings, ohne Probes) und Logs über OTLP; das Image setztOTEL_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öße | ein natives Programm, startet schnell, kleineres Image | .NET-Runtime, langsamerer Start |
| Oracle | – | mit eigenem Treiber |
| SQL Server | SQL-Logins | auch Entra ID / Managed Identity, integrierte Sicherheit, Always Encrypted |
| ZPL-Erzeugung von Datensätzen im Speicher | auf allen Kernen (output:generationThreads 0) | seriell (byte-gleiche Ausgabe) |
| Plattformen | linux/amd64, linux/arm64 | linux/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 –
Acceptnennt einen nicht unterstützten Typ, oder PNG/SVG für mehrere Etiketten (application/zipoderapplication/pdfverwenden). - 413 – Bildausgaben über
limits:maxRenderLabels(für große Aufträge ZPL verwenden), ein Bild über den Bildgrenzen oder ein Body überlimits:maxRequestBodyBytes. - 422 –
titleunddetaillesen: „Unknown field“ (den Namen als Auftragsvariable mitschicken), „Font not available“, „Label generation failed“ (ein Wert oder Ausdruck – das Log hat die Details unter dertraceId), „Invalid template“, „Printer twin check failed“. - 429 – alle Render-Slots belegt:
limits:maxConcurrentRenders/maxQueuedRenderserhöhen oder Replikate hinzufügen; ohne Body: das Rate-Limit. - 502 – eine Vorlagenquelle, Datenbank, ein REST-Dienst oder Drucker ist nicht erreichbar;
detailnennt, welcher (nie Zugangsdaten). - 504 –
limits:requestTimeout, dertimeouteiner Abfrage oder ein Drucker, der~HSnicht beantwortet.
Vorlagen werden nicht geladen
GET /v1/templateslistet Dateien, die nicht geladen werden konnten, mit Grund (errors) und jede Quelle mit ihrem letzten Fehler;/ready(angemeldet) zeigtproblems.- 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 alsmaxObjectsObjekte? git-sync: istpollkurz 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 mittwin: blockFehler 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: graphicverwenden (Schriften). - Falsche Größe oder Position:
dpiundmodeldes Druckers inzplcore.yamlmüssen zum Gerät passen.

