KYTH.Karl Shipping API v1 ENTERPRISE

REST-API, um KYTH.Karl aus deinem eigenen System (Dashboard / ERP / WMS) zu steuern: Bestellungen lesen, Carrier vergleichen, Labels erstellen + drucken. Das Sandbox-/Live- Verhalten richtet sich nach deiner Carrier-Konfiguration in KYTH.Karl.

1. API-Key erstellen

  1. In KYTH.Karl: Einstellungen → System → API-Zugang → „Neuen Key erstellen".
  2. Der Key (karl_live_…) wird genau einmal im Klartext angezeigt — sofort sicher speichern. KYTH.Karl speichert nur einen Hash; der Key kann nicht erneut angezeigt werden.
  3. Der Key gehört ausschließlich auf deinen Server (Dashboard-Backend). Niemals im Browser/Frontend.
Voraussetzung: Der Shop muss im Enterprise-Plan sein. Jedes erstellte Label wird wie in der App abgerechnet (Per-Label-Gebühr des Plans).

2. Der typische Ablauf

1. GET  /api/v1/orders                → offene Bestellungen anzeigen
2. POST /api/v1/orders/{id}/rates     → Carrier-Optionen + Preise holen
3. (Mitarbeiter wählt einen Carrier)  → "Carrier wechseln"
4. POST /api/v1/orders/{id}/labels    → Label erstellen + drucken

3. Endpoints

GET /api/v1/orders

Query: status (unfulfilled|partial|fulfilled|restocked|unshipped, Default unfulfilled), limit (max 250), after (Cursor).

{
  "orders": [
    { "id": "1042", "name": "#1042", "created_at": "…", "email": "kunde@example.com",
      "total_weight_g": 2500, "customs_status": "ok",
      "recipient": { "first_name": "Max", "last_name": "Müller", "address1": "Hauptstr. 1",
                     "zip": "25980", "city": "Sylt", "country_code": "DE", "phone": "…" },
      "line_items": [ { "title": "Widget", "sku": "W1", "variant_id": "44012345678", "quantity": 2, "open_quantity": 2, "grams": 1200, "hs_code": "…" } ],
      "package_resolved": { "length_cm": 24, "width_cm": 18, "height_cm": 17, "weight_g": 700,
                            "source": "variant_metafield" } }
  ],
  "count": 1, "has_next": false, "next_cursor": ""
}

open_quantity (seit 1.3.77): die noch nicht versendete Menge der Position (0 bis quantity). Ist ein Teil der Bestellung schon versendet, rechnen package_resolved und das Label für den Rest nur mit dieser Menge — das Gesamtgewicht der Bestellung gilt dann nicht als Quelle, weil es Versendetes mitzählt. package_resolved.decided_by_line_item (die Position, deren Maße den Karton bestimmen) ist die Stelle in line_items der Bestellung (ab 0, alle Positionen wie oben); gewählt wird nur unter den offenen.

GET /api/v1/orders/{order_id}

Eine einzelne Bestellung (Schema wie oben, ein Objekt).

POST /api/v1/orders/{order_id}/rates

Carrier-Vergleich (Smart Routing). package ist optional — lässt du es weg, löst KYTH.Karl die Maße selbst auf (wie in der App) und nennt die Quelle im package_used-Feld.

Woher kommen die Maße? KYTH.Karl liest sie aus dem Shopify-Metafeld (Varianten-Ebene, ersatzweise Produkt-Ebene) karl_dims.dimensions (Wert im Format LxBxH in cm, z. B. 24x18x17; alternativ drei Dezimalzahl-Felder karl_dims.length_cm / width_cm / height_cm). Auflösungsreihenfolge: karl_dims an der Variante → dieselben Felder am Produkt (nur als vollständiges Tripel, nie aus beiden Ebenen gemischt) → Standardpaket aus den Einstellungen. Bei mehreren Positionen gewinnt das größte Volumen. Das Gewicht ist die Summe der Positionsgewichte. Schickst du package mit, gilt dein Wert unverändert.

// Request — Variante A: KYTH.Karl löst die Maße auf
{}

// Request — Variante B: eigene Maße
{ "package": { "weight_g": 2500, "length_cm": 30, "width_cm": 20, "height_cm": 15 } }

// Response
{ "options": [
  { "carrier": "dhl", "service": "V01PAK", "service_name": "DHL Paket",
    "price": 4.95, "currency": "EUR", "eta_days": 1, "surcharge": null, "recommended": true,
    // Volumengewicht-Transparenz (je Option):
    "actual_weight_g": 700, "volumetric_weight_g": 1224, "billable_weight_g": 1224,
    "volumetric_divisor": 5000,
    "weight_hint": "Volumengewicht bindend — ein kleinerer Karton spart Porto." }
], "count": 1,
  "package_used": { "weight_g": 700, "length_cm": 24, "width_cm": 18, "height_cm": 17,
                    "source": "variant_metafield" } }   // source: variant_metafield | default_package | request | none

Volumengewicht: Express-Carrier berechnen nach billable_weight_g = max(actual, volumetric), wobei volumetric = L×B×H ÷ divisor. Ist weight_hint gesetzt, bindet das Volumengewicht — dann entscheidet der Karton über den Preis, nicht das Artikelgewicht. volumetric_weight_g/volumetric_divisor sind null bei Carriern ohne Volumengewicht (z. B. DHL Paket).

FedEx-Wertdeklaration im Tarif: FedEx-Optionen fragen FedEx mit dem Declared Value an, den das Label melden würde (Warenwert der Bestellung + Shop-Einstellung, oder declared_value im Body). Jede Option nennt declared_value, declared_value_fee (die Gebühr laut FedEx, in price enthalten), declared_value_reason und postage (price ohne die Gebühr). Bei anderen Carriern sind die drei declared_value-Felder null und postage = price. Details im Abschnitt „FedEx-Wertdeklaration“ unten.

Packstation und DHL-Postfiliale (seit 1.3.17): Ist die Lieferadresse eine DHL Packstation oder DHL-Postfiliale in Deutschland (erkannt an address1/address2, z. B. „Packstation 138“ oder „Postfiliale 512“ — nie an Firma oder Name), enthält options nur DHL Paket (carrier: "dhl"), auch keine Portokasse. Die Antwort trägt dann zusätzlich packstation_schutz; ohne Abholstelle fehlt das Feld. Ist DHL Paket im Shop nicht nutzbar, ist options leer und meldung nennt den Grund. Ist DHL Paket nutzbar, hat für die Sendung aber keinen passenden Tarif (Gewicht, Maße, Versicherung), ist options ebenfalls leer und meldung sagt das (ohne „Bitte wähle DHL Paket“). absender_land ist das Absenderland des Shops. Für Empfänger außerhalb Deutschlands ändert sich nichts.

"packstation_schutz": { "aktiv": true, "abholstelle": "packstation",   // oder "postfiliale"
                        "erlaubt": ["dhl"], "dhl_eingerichtet": true, "absender_land": "DE",
                        "meldung": "An eine DHL Packstation kann nur DHL Paket zustellen. Bitte wähle DHL Paket." }

Adresse fürs Label (seit 1.3.20): Ändert KYTH.Karl die Lieferadresse fürs Label (z. B. „12 Hauptstraße“ → „Hauptstraße 12“, eine Eircode in die Form „D02 X285“) oder hat es dazu etwas zu melden (Packstation, fehlende Postnummer, Eircode), trägt die Antwort zusätzlich address — sonst fehlt das Feld. Der Carrier steht hier noch nicht fest; gesperrt wird erst beim Label. Die options ändern sich dadurch nicht. Aufbau im Abschnitt „Adresse fürs Label“ unten.

service ist der buchbare Code — gib ihn unverändert an /labels weiter. service_name ist nur für die Anzeige.
⚠ Geändert am 11.09.2026: Bis dahin stand in service der Anzeigename (z. B. „UPS Standard“), obwohl /labels den Code erwartet — ein daraus erzeugtes Label scheiterte. Zeigt dein System service als Text an, stell die Anzeige auf service_name um. Namen werden bei /labels übergangsweise weiter akzeptiert und in den Code übersetzt.

POST /api/v1/orders/{order_id}/labels

Label erstellen + (optional) drucken. Header Idempotency-Key ist Pflicht (max 200 Zeichen).

// Header:  Idempotency-Key: <eindeutige ID pro Versuch, z.B. UUID>
// Request
{ "carrier": "ups", "service": "11",       // carrier + service UNVERÄNDERT aus /rates
  "package": { "weight_g": 2500, "length_cm": 30, "width_cm": 20, "height_cm": 15 },
  "print": "companion" }   // "companion" (Druck via KYTH.Karl-Companion) | "none" (nur PDF zurück)

// Response
{ "tracking_number": "1Z…", "carrier": "ups", "service": "11",
  "cost": 6.20, "currency": "EUR", "label_base64": "JVBERi0…",
  "printed": true, "fulfilled": true,
  "declared_value": null, "declared_value_fee": null,   // FedEx: siehe „FedEx-Wertdeklaration“
  "invoice_source": null, "plt_uploaded": null }        // DHL Express: Handelsrechnung (seit 1.3.24)

Das Label wird automatisch im Shopify-Fulfillment hinterlegt (Tracking an den Kunden). Bei print: "none" druckst du das label_base64-PDF selbst.

Vor der Buchung prüft KYTH.Karl, ob der Dienst für diese Sendung wirklich angeboten wird. Nicht buchbar → 400 service_unavailable; in details.available stehen die buchbaren Codes, und der Idempotency-Key bleibt frei. Prüfung gerade nicht möglich (Carrier nicht erreichbar) → 503 service_check_failed; es wurde nichts gebucht, später mit demselben Key erneut senden. Die Prüfung ist eine echte Preisabfrage — direkt nach /rates ist sie schnell.

Packstation und DHL-Postfiliale: Geht die Sendung an eine DHL Packstation oder DHL-Postfiliale in Deutschland und ist ein anderer Carrier als dhl gewählt oder DHL Paket im Shop nicht nutzbar → 422 packstation_nur_dhl_paket, noch vor der Dienst-Prüfung. Es wird nichts gebucht, der Idempotency-Key bleibt frei. details nennt abholstelle (packstation | postfiliale), carrier, dhl_eingerichtet und erlaubt (["dhl"]).

Bundesstaat (USA, Kanada, Australien, Mexiko): Fehlt in der Lieferadresse der Bundesstaat bzw. die Provinz oder passt er nicht zum Land → 422 bundesstaat_fehlt, ebenfalls vor der Dienst-Prüfung — die Carrier nehmen solche Sendungen nicht an. Es wird nichts gebucht, der Idempotency-Key bleibt frei. Steht in der Shopify-Adresse nur der Name (province: "New York"), leitet KYTH.Karl den Code selbst ab. details nennt feld (province_code), land, grund (fehlt | ungueltig) und wert.

Postnummer und Eircode (seit 1.3.20): Mit carrier: "dhl" an eine DHL Packstation oder DHL-Postfiliale ohne Postnummer → 422 packstation_postnummer_fehlt (bis 1.3.19 kam das als 400 carrier_error). Mit carrier: "fedex" nach Irland ohne Eircode oder mit einer Eircode in falscher Form → 422 eircode_fehlt bzw. 422 eircode_ungueltig. Beides ebenfalls vor der Dienst-Prüfung: Es wird nichts gebucht, der Idempotency-Key bleibt frei; Code und Text sind dieselben wie im Trockenlauf. Mehr im Abschnitt „Adresse fürs Label“.

Unvollständige Lieferadresse: Fehlt in der Lieferadresse der Bestellung ein Pflichtfeld (Straße, PLZ, Ort, Land, Name oder Firma) — etwa die PLZ bei einer irischen Adresse ohne Eircode —, antwortet der Kauf mit 400 invalid_recipient, details.missing nennt die Felder (bis 1.3.19 kam das als 400 carrier_error). Ebenfalls vor der Dienst-Prüfung, der Idempotency-Key bleibt frei; der Trockenlauf meldet denselben Code und Text vorher in errors.

Teilweise versendete Bestellung (seit 1.3.77): Ist ein Teil der Bestellung schon versendet (in der App, außerhalb von KYTH.Karl oder über die API), wird das Label eine Teilsendung über die offenen Positionen: Zollerklärung, Versicherung und Warenwert enthalten nur die noch offenen Mengen (open_quantity), und KYTH.Karl markiert in Shopify nur diese Positionen als versendet; das erste Teil-Label bleibt aktiv. Tragen die Positionen keine Kennung → 422 partial_shipment_unavailable (dann wäre nur die ganze Bestellung markierbar) — vor der Dienst-Prüfung, nichts gebucht, der Idempotency-Key bleibt frei. Der Trockenlauf meldet denselben Code in errors; checks.partial_shipment gibt es nur bei einer teilweise versendeten Bestellung. Ausnahme: eine eBay-Bestellung (ebay:…), für die KYTH.Karl schon ein aktives Label hat — eBay führt eine Position auch dann als offen, wenn nur die Sendungsnummer dort nicht ankam. Dann bucht KYTH.Karl kein Teil-Label, der Kauf endet wie bis 1.3.76 mit 409 (der Text nennt das vorhandene Label), und der Trockenlauf meldet keine checks.partial_shipment.

Trockenlauf (dry_run): Setz "dry_run": true im Body — als echten Wahrheitswert, nicht als Text "true" (das ergibt 400 invalid_request) —, um deinen Flow zu testen, ohne echtes Porto zu kaufen. KYTH.Karl validiert Paket, Zoll und Carrier/Service-Verfügbarkeit und meldet alle Probleme auf einmal — ohne Buchung, Charge, Fulfillment oder Druck. Kein Idempotency-Key nötig (kein Seiteneffekt).

Der Trockenlauf spiegelt den echten Pfad: package gehört mitgeschickt (aus /rates package_used) — fehlt es, meldet der Report checks.package: false + einen invalid_package-Fehler, genau wie die echte Erstellung.

// Request
{ "carrier": "ups", "service": "11", "dry_run": true,
  "package": { "weight_g": 700, "length_cm": 24, "width_cm": 18, "height_cm": 17 } }

// Response
{ "dry_run": true, "valid": true, "carrier": "ups", "service": "11", "service_name": "UPS Standard",
  "checks": { "package": true, "customs_item_data": true, "customs_validation": true, "address": true,
              "service_available": true },
  "errors": [],                          // bei valid:false z.B. {"code":"customs_validation_failed", …}
  "warnings": [],
  "address": { "vorher": { … }, "nachher": { … }, "korrekturen": [], "abholstelle": null,
               "hinweise": [], "fehler": [] },   // seit 1.3.20, siehe „Adresse fürs Label“
  "package_used": { "weight_g": 700, "length_cm": 24, "width_cm": 18, "height_cm": 17, "source": "variant_metafield" },
  "estimated_cost": 6.20, "currency": "EUR", "postage": 6.20,
  "declared_value": null, "declared_value_fee": null, "declared_value_reason": null, "customs_value": null }

// Response — dieselbe Anfrage an eine DHL Packstation (UPS stellt dort nicht zu)
{ "dry_run": true, "valid": false, "carrier": "ups", "service": "11",
  "checks": { "abholstelle": false, … },
  "errors": [ { "code": "packstation_nur_dhl_paket",
                "message": "An eine DHL Packstation kann nur DHL Paket zustellen. Bitte wähle DHL Paket." } ],
  … }

checks.abholstelle gibt es nur, wenn die Lieferadresse eine DHL Packstation oder DHL-Postfiliale in Deutschland ist: true, wenn der gewählte Carrier dort buchbar ist (dhl, DHL Paket im Shop nutzbar), sonst false plus der Fehler packstation_nur_dhl_paket — die echte Buchung antwortet dann mit 422 packstation_nur_dhl_paket.

Fehlt bei einem Empfänger in den USA, in Kanada, Australien oder Mexiko der Bundesstaat bzw. die Provinz, meldet der Trockenlauf valid: false, checks.recipient_state: false (das Feld gibt es nur bei diesen vier Ländern) und in errors:

{ "code": "bundesstaat_fehlt", "field": "province_code", "country": "US",
  "message": "Für Sendungen in die USA braucht der Carrier den Bundesstaat bzw. die Provinz des Empfängers. Bitte in der Adresse ergänzen." }

Die echte Buchung antwortet dann mit 422 bundesstaat_fehlt.

Seit 1.3.20 zeigt der Trockenlauf außerdem, was aufs Label geht: address, checks.address (false bei fehlender Postnummer bzw. bei FedEx nach Irland ohne gültige Eircode, dazu der Fehler in errors) und je automatischer Korrektur einen Eintrag adresse_korrigiert in warnings — siehe „Adresse fürs Label“.

CPSC (US-Produktsicherheit): Trägt eine Position eine CPSC-Angabe (Shopify-Metafeld karl_compliance.cpsc, Format Certifier-ID / Product-ID / Version-ID), übermittelt KYTH.Karl sie bei Sendungen in die USA an FedEx elektronisch (Registry-Verweis) und an UPS im Kommentar der Handelsrechnung (höchstens 150 Zeichen je Sendung). Das blockiert nie. Hinweise stehen in warnings — im Trockenlauf und in der Label-Antwort — als { "code", "message", "items" } (items = 0-basierte Positionen): cpsc_format_nicht_erkannt (Wert nicht im Format; er geht nur als Text auf die Rechnung, FedEx bekommt keinen Registry-Verweis) und cpsc_ups_kommentar_voll (passte nicht mehr ins UPS-Kommentarfeld — über ups.com nachreichen).

Seit 1.3.23 geht die CPSC-Angabe bei DHL Express (Ziel USA) als eine Bemerkung („Remarks“, höchstens 300 Zeichen) auf die Handelsrechnung, die DHL erstellt. Passt nicht alles hinein, meldet die Label-Antwort cpsc_dhl_express_bemerkung_voll in warnings ({ "code", "message" }, ohne items; der Trockenlauf meldet es nicht) — die vollständigen Angaben dann dem Paket beilegen.

Handelsrechnung bei DHL Express (seit 1.3.23): DHL erstellt sie selbst aus den Zolldaten. Nur in zwei Rückfällen kommt sie von KYTH.Karl; die Label-Antwort meldet das in warnings als { "code", "message" }: rechnung_zum_druck (DHL hat gebucht, aber keine Rechnung erzeugt — elektronisch liegt bei DHL keine; die Rechnung von KYTH.Karl in der App unter „Zollrechnung“ ausdrucken und dem Paket beilegen), rechnung_fehlt (dasselbe, und auch KYTH.Karl konnte keine erstellen) und rechnung_von_karl (DHL lehnte die Rechnungserstellung ab, KYTH.Karl hat seine eigene elektronisch mitgeschickt — nur zur Info).

Handelsrechnung bei UPS über eine Zollgrenze in die EU (seit 1.3.24): UPS bekommt die elektronische Handelsrechnung (mit dem Grund der Ausfuhr) von KYTH.Karl nur bei Zielen außerhalb der EU. Überquert eine UPS-Sendung trotzdem eine Zollgrenze — etwa ein Schweizer Absender nach Deutschland —, meldet die Label-Antwort in warnings rechnung_beilegen: eine Handelsrechnung ausdrucken und dem Paket beilegen; unter „Zollrechnung“ liegt für diese Sendung keine.

Seit 1.3.24 steht dasselbe zusätzlich als Feld in der Label-Antwort (auch in der Wiederholung und in GET /api/v1/labels/{idempotency_key}):

Weitere optionale Felder: incoterm (DAP | DDP | DDU; weglassen = Shop-Standard), insurance_value und — nur FedEx — declared_value (siehe nächster Abschnitt).

FedEx-Wertdeklaration (declared_value) und insurance_value

declared_value gilt nur für FedEx: der Betrag, den KYTH.Karl FedEx als Declared Value meldet — in der Währung der Sendung, höchstens der Warenwert. Ohne Wertdeklaration haftet FedEx bei Verlust oder Schaden nur nach Gewicht (Montrealer Übereinkommen, rund 30 € je kg).

insurance_value ist der Versicherungswert in EUR für UPS, DHL und die übrigen Carrier (weglassen = Shop-Einstellung, 0 = keine, Betrag = dieser Wert). Bei FedEx meldet KYTH.Karl seit 1.3.16 ausschließlich den Declared Value: Schickst du dort nur insurance_value, liest KYTH.Karl ihn als declared_value (0 = keine) und sagt es in warnings (insurance_value_as_declared_value). Mit beiden Feldern zählt declared_value.

Die Gebühr nennt FedEx selbst. Sie hängt vom Paket ab — gemessen fällt oft nichts an, ab etwa 50 € deklariertem Wert (je nach Gewicht) rund 0,92 € je Stufe. KYTH.Karl rechnet sie nie selbst aus: Tarif und Trockenlauf fragen FedEx mit genau dem Betrag an, den das Label melden wird, und zeigen die Gebühr von FedEx, bevor du buchst.

// Trockenlauf FedEx, Warenwert 190 EUR, Einstellung „bis Freigrenze 100 €“ an
{ "dry_run": true, "valid": true, "carrier": "fedex", "service": "INTERNATIONAL_PRIORITY",
  "estimated_cost": 48.57, "currency": "EUR", "postage": 47.65,
  "declared_value": { "amount": 100.0, "currency": "EUR" }, "declared_value_reason": "free_limit",
  "declared_value_fee": 0.92, "declared_value_fee_currency": "EUR",
  "customs_value": { "amount": 190.0, "currency": "EUR" }, … }

// dieselbe Sendung ohne Wertdeklaration
{ "carrier": "fedex", "service": "INTERNATIONAL_PRIORITY", "dry_run": true, "declared_value": 0, … }

Adresse fürs Label (address), Postnummer und Eircode

Seit 1.3.20 zeigen Trockenlauf, Entwurf und Tarife, was aufs Label geht — dieselbe Deutung, mit der KYTH.Karl beim Kauf bucht, ohne Carrier-Aufruf. KYTH.Karl schreibt nichts an Shopify zurück: Die Bestellung bleibt, wie sie ist; die Korrekturen gelten nur fürs Label.

// Trockenlauf DHL an eine Packstation, die Postnummer steht im Feld Firma
"valid": true,
"checks": { "address": true, … },
"warnings": [ { "code": "adresse_korrigiert",
                "message": "Empfängeradresse automatisch korrigiert: Postnummer aus dem Feld Firma übernommen (12345678 → Postnummer 12345678)",
                "before": "12345678", "after": "Postnummer 12345678" } ],
"address": {
  "vorher":  { "address1": "Packstation 138", "address2": "", "company": "12345678", "zip": "10115" },
  "nachher": { "street": "Packstation 138", "address2": "", "zip": "10115" },
  "korrekturen": [ { "typ": "postnummer_aus_firma", "label": "Postnummer aus dem Feld Firma übernommen",
                     "vorher": "12345678", "nachher": "Postnummer 12345678" } ],
  "abholstelle": { "art": "packstation", "nummer": "138", "postnummer": "12345678",
                   "strasse": "Packstation 138", "firma_ist_postnummer": true },
  "hinweise": [], "fehler": [] }

// dieselbe Packstation ohne Postnummer → "valid": false, "checks": { "address": false, … }
"errors": [ { "code": "packstation_postnummer_fehlt", "field": "postnummer",
              "message": "Für eine Packstation braucht DHL die Postnummer des Empfängers (6–10 Ziffern). Keine Postnummer gefunden" } ]

// FedEx nach Irland ohne Eircode
"errors": [ { "code": "eircode_fehlt", "field": "zip", "country": "IE",
              "message": "FedEx braucht für Irland eine Eircode (z. B. D02 X285). Bitte in der Adresse ergänzen." } ]

POST /api/v1/rates  ·  POST /api/v1/labels

Label ohne Bestellung — wie „Manuelles Label“ in der App, etwa für Walk-ins, B2B-Sendungen oder Ersatzlieferungen, die es in Shopify nicht als Bestellung gibt. Empfänger und Paket kommen aus dem Body, Absender ist die Absenderadresse aus den Einstellungen. Es gibt kein Shopify-Fulfillment (fulfilled ist immer false).

// 1) POST /api/v1/rates
{ "recipient": { "company": "Muster AG", "first_name": "Max", "last_name": "Muster",
                 "address1": "Bahnhofstrasse 1", "zip": "8001", "city": "Zürich", "country_code": "CH",
                 "phone": "+41 44 000 00 00", "email": "max@muster.ch" },
  "package": { "weight_g": 2500, "length_cm": 30, "width_cm": 20, "height_cm": 15 } }
// → { "options": [ … wie bei /orders/{order_id}/rates … ], "count": 4,
//     "package_used": { "weight_g": 2500, "length_cm": 30, "width_cm": 20, "height_cm": 15, "source": "request" } }

// 2) POST /api/v1/labels      Header: Idempotency-Key
{ "recipient": { … wie oben … },
  "package": { "weight_g": 2500, "length_cm": 30, "width_cm": 20, "height_cm": 15 },
  "carrier": "fedex", "service": "INTERNATIONAL_PRIORITY",   // unverändert aus /api/v1/rates
  "reference": "Ersatzlieferung 4711",                      // optional, steht wie eine Bestellnummer im Archiv
  "items": [ { "description": "Holzrelief Eiche", "quantity": 1, "weight_g": 2300,
               "value": 120.00, "currency": "EUR", "hs_code": "44209990", "origin_country": "DE" } ],
  "print": "companion" }
// → { "tracking_number": "…", "carrier": "fedex", "service": "INTERNATIONAL_PRIORITY",
//     "cost": 93.69, "currency": "EUR", "label_base64": "JVBERi0…", "printed": true, "fulfilled": false }

Empfänger: Pflicht sind country_code, address1, zip, city und company oder ein Name. Fehlt etwas → 400 invalid_recipient, in details.missing stehen die fehlenden Felder.

Bundesstaat (USA, Kanada, Australien, Mexiko): dazu province_code (z. B. "NY", "ON", "NSW") oder province mit dem Namen ("New York" — KYTH.Karl leitet den Code ab). Fehlt er oder passt er nicht zum Land → 422 bundesstaat_fehlt (Trockenlauf: Fehler in errors, checks.recipient_state: false). Nichts gebucht, der Idempotency-Key bleibt frei.

Packstation und DHL-Postfiliale: Steht in address1 oder address2 eines Empfängers in Deutschland eine DHL Packstation oder DHL-Postfiliale (z. B. „Packstation 138“, die Postnummer in address2), liefert /api/v1/rates nur DHL-Paket-Optionen plus packstation_schutz, und /api/v1/labels nimmt nur carrier: "dhl" an — sonst 422 packstation_nur_dhl_paket (Trockenlauf: checks.abholstelle: false), wie bei /orders/{order_id}/labels.

Postnummer und Eircode: wie bei /orders/{order_id}/labels — 422 packstation_postnummer_fehlt (carrier: "dhl", Postnummer in address2) und 422 eircode_fehlt / 422 eircode_ungueltig (carrier: "fedex" nach Irland), vor Varianten, Dienst-Prüfung und Idempotency-Key. Der Trockenlauf nennt address (siehe „Adresse fürs Label“).

items sind die Zollpositionen. Ins Drittland (z. B. Schweiz) Pflicht: je Position description, weight_g und value (sonst 422 customs_data_missing) sowie hs_code und origin_country (sonst 422 customs_validation_failed). Jede Position braucht außerdem currency (sonst 400 invalid_items). weight_g und value gelten je Stück. Optional je Position compliance: { "cpsc": "Certifier-ID / Product-ID / Version-ID" } — die CPSC-Angabe für US-Sendungen (siehe oben); nur der Schlüssel cpsc, Text bis 300 Zeichen, sonst 400 invalid_items. Ein anderes Format ist kein Fehler: Der Text geht dann nur auf die Rechnung, warnings meldet cpsc_format_nicht_erkannt. Ohne variant_id oder sku setzt KYTH.Karl keine Zollwerte selbst ein, die Währung nie (siehe „Positionen aus Shopify übernehmen“). dry_run, incoterm, insurance_value, declared_value und print funktionieren wie bei /orders/{order_id}/labels (siehe „FedEx-Wertdeklaration“). Grundlage der Deklaration ist der Warenwert der items (Wert × Menge); bei Positionen in verschiedenen Währungen deklariert KYTH.Karl nichts (declared_value_reason: "mixed_currency"). /api/v1/rates kennt keine items: Für FedEx-Gebühren im Tarif schick dort goods_value (Warenwert) mit goods_currency (ISO-Code, dazu Pflicht) und optional declared_value — ohne sie fragt KYTH.Karl FedEx ohne Wertdeklaration an.

Der Idempotency-Key ist an den Inhalt gebunden. Schickst du denselben Key mit anderem Empfänger, Paket, Dienst oder anderen Positionen, antwortet KYTH.Karl mit 422 idempotency_key_conflict — nie mit dem Label der ersten Anfrage. Eine echte Wiederholung (gleicher Key, gleicher Inhalt) bekommt das bereits erstellte Label zurück.

Positionen aus Shopify übernehmen (variant_id / sku)

Statt der Einzelwerte darf eine Position variant_id oder sku tragen. Dann belegt KYTH.Karl, was fehlt, aus der Shopify-Variante — aus denselben Feldern, die es bei Bestellungen liest:

FeldQuelle in Shopify
hs_codeHS-Code für das Zielland (Quelle country_rule), sonst der HS-Code der Variante
origin_countryUrsprungsland der Variante
descriptionMetafeld karl_customs.description oder custom.customs_description, sonst der Produkttitel
weight_gGewicht der Variante (je Stück)
valuePreis der Variante — nur wenn die Shopwährung der currency der Position entspricht
complianceMetafeld karl_compliance.cpsc (Variante vor Produkt) als { "cpsc": "…" } — nur wenn die Position keine CPSC-Angabe mitschickt
"items": [ { "variant_id": "44012345678", "quantity": 12, "value": 77.35, "currency": "EUR" } ]

POST · GET /api/v1/drafts  ·  GET · PATCH · DELETE /api/v1/drafts/{id}  ·  POST /api/v1/drafts/{id}/labels

Entwürfe — Labels vorbereiten, aber noch nicht kaufen. Es sind dieselben Entwürfe wie „Als Entwurf speichern“ im Manuellen Label: API-Entwürfe erscheinen in der App und umgekehrt.

// Anlegen: Body wie POST /api/v1/labels, dazu "name", ohne dry_run — es wird nichts gekauft
POST /api/v1/drafts
{ "name": "THE ICON Paket 1 FedEx",
  "recipient": { … }, "package": { "weight_g": 9900, "length_cm": 40, "width_cm": 40, "height_cm": 40 },
  "carrier": "fedex", "service": "INTERNATIONAL_PRIORITY", "incoterm": "DDP",
  "reference": "S-26-00067 THE ICON", "items": [ … ], "print": "companion" }
// → { "id": 101, "name": "THE ICON Paket 1 FedEx", "created_at": "…", "updated_at": "…",
//     … gespeicherte Felder …, "valid": true, "checks": { … }, "errors": [], "warnings": [],
//     "estimated_cost": 93.69, "currency": "EUR", "items_resolved": [ … ] }

GET    /api/v1/drafts?reference=S-26-00067%20THE%20ICON&limit=20   // neueste zuerst, "next_after" → ?after=
GET    /api/v1/drafts/101
PATCH  /api/v1/drafts/101          { "service": "INTERNATIONAL_ECONOMY" }   // jedes Feld ersetzt das gespeicherte
DELETE /api/v1/drafts/101
POST   /api/v1/drafts/101/labels   Header: Idempotency-Key   { "print": "none" }   // optional: dry_run, print

GET /api/v1/labels/{idempotency_key}

Kauf-/Status eines Label-Requests abfragen — ohne einen erneuten POST zu wagen. Praktisch nach einem Timeout: hat der erste Versuch gekauft oder nicht?

{ "idempotency_key": "7d3f-uuid",
  "status": "completed",     // "in_progress" | "completed" | "unknown"
  "purchased": true,         // true = Label existiert
  "tracking_number": "1Z…", "carrier": "ups", "service": "11",
  "cost": 6.20, "currency": "EUR", "printed": true, "fulfilled": true,
  "declared_value": null, "declared_value_fee": null,   // FedEx: an FedEx gemeldeter Wert + Gebühr (EUR)
  "invoice_source": null, "plt_uploaded": null }        // DHL Express: Handelsrechnung (seit 1.3.24)

Unbekannter Key → 404 idempotency_key_not_found.

POST /api/v1/labels/{tracking_number}/void

Label stornieren — beim Carrier, nicht nur in KYTH.Karl. Funktioniert für Labels mit und ohne Bestellung. Gehört zu einer Bestellung kein aktives Versandlabel mehr, nimmt KYTH.Karl auch das Shopify-Fulfillment zurück.

POST /api/v1/labels/1ZGV13950417264545/void
{ "notify_customer": false }        // optional; ohne Angabe entscheidet deine Mail-Vorlage

// Antwort
{ "tracking_number": "1ZGV13950417264545", "carrier": "ups",
  "voided": true,                   // true = der Carrier hat bestätigt
  "carrier_confirmed": true,
  "message": "UPS Label storniert" }

GET /api/v1/shipments/{tracking_number}

Sendungsstatus und voraussichtliches Zustelldatum zu einer Tracking-Nummer, mit den Stationen beim Carrier. Ist der gespeicherte Stand älter als 15 Minuten (oder gibt es noch keinen), fragt KYTH.Karl vor der Antwort beim Carrier nach — über die API höchstens einmal je Sendung und 15 Minuten, Antwort nach spätestens etwa 20 Sekunden. Klappt das nicht, kommt der letzte Stand mit "stale": true, nie ein 5xx. Bei DHL Paket fragt KYTH.Karl über den GKP-Login deines Shops (eigenes Kontingent von 1.000 Abfragen am Tag, bis zu 3 Abfragen je Sekunde für alle Shops zusammen); zugestellte Sendungen fragt KYTH.Karl dort nicht erneut ab. Über den gemeinsamen Zugang — Sendungen von vor dem Umstieg und solange DHL deinen Zugang ablehnt — geht höchstens eine Abfrage alle 5 Sekunden zu DHL: Mehrere DHL-Sendungen gleichzeitig abgerufen, kommen dann einige mit "stale": true — ein erneuter Abruf frischt sie auf; am besten nacheinander abrufen. Sendungen, die niemand abruft, behalten ihren letzten Stand (auch in der Liste). Nur Sendungen deines eigenen Shops.

{ "tracking_number": "1Z…", "carrier": "ups", "service": "11",
  "status": "transit",             // label_created | pickup | transit | out_for_delivery | delivered
                                   // | returned | exception | unknown | not_supported | pending
  "status_label": "Unterwegs", "delivered": false,
  "estimated_delivery": "2026-07-21",
  "tracking_url": "https://www.ups.com/track?tracknum=1Z…",
  "is_return": false,
  "order": { "id": "7335812137023", "name": "#1028" },
  "created_at": "2026-07-17T10:59:33Z",
  "status_updated_at": "2026-07-18T06:00:12Z",
  "created_by": "Anna",            // wer die Sendung erstellt hat
  "created_by_kind": "staff",      // staff | system | api
  "stale": false,                  // true = Stand älter als 15 Min., Auffrischen ging gerade nicht
  "exception": null,               // letztes Zustellproblem: { code, text, at, location }
  "events": [                      // Stationen, neueste zuerst (höchstens 30)
    { "at": "2026-07-18T07:55:00", "code": "I", "text": "Arrived at Facility", "location": "Neuss, DE" } ] }

GET /api/v1/shipments

Alle Sendungen eines Zeitraums, neueste zuerst — für den Tagesabgleich, ohne jede Sendung einzeln abzufragen. Jede Zeile hat dasselbe Format wie beim Einzelabruf oben, ohne stale, exception und events; die Liste fragt nicht beim Carrier nach.

Query: from und to (JJJJ-MM-TT, Kalendertage in deutscher Zeit, to inklusive; ohne to gilt heute, ohne from derselbe Tag wie to), limit (1–200, Standard 50), after (Wert aus next_after).

GET /api/v1/shipments?from=2026-09-18&to=2026-09-18&limit=100

{ "shipments": [ { "tracking_number": "1Z…", "carrier": "ups", "status": "transit",
                   "created_by": "Anna", "created_by_kind": "staff" } ],   // gekürzt
  "count": 100,
  "next_after": "MjAyNi0wOS0xOFQxNDowMjoxMS41MTIzNDV8NDgxMg" }        // null = letzte Seite

GET · PUT /api/v1/settings/default-package

Das Standardpaket lesen/setzen — dein Sicherheitsnetz für alle Varianten ohne gepflegte Maße. Für Onboarding-Automatisierung, damit du es nicht nur im UI setzen kannst.

// GET → aktuelles Standardpaket
{ "configured": true, "name": "Standardpaket",
  "length_cm": 30, "width_cm": 20, "height_cm": 15, "max_weight_kg": 30.0,
  "default_weight_g": 500 }   // Standardgewicht (nur lesbar; wird in der App gesetzt)

// PUT → Maße setzen (max_weight_kg optional)
{ "length_cm": 30, "width_cm": 20, "height_cm": 15, "max_weight_kg": 31.5 }

GET /api/v1/openapi.json

Maschinenlesbare Beschreibung (OpenAPI 3.0) aller Endpoints — öffentlich, ohne API-Key. Damit generieren sich Clients selbst und KI-Assistenten raten nicht. Enthält keine Shop-Daten.

Wie KYTH.Karl die Paketmaße auflöst

Gilt überall, wo der Server die Maße selbst bestimmt (package weggelassen, package_resolved, Trockenlauf):

GET /api/v1/packaging/coverage

Datenqualitäts-Check für die Paketmaße: Wie viele deiner versandrelevanten Varianten haben ein gepflegtes karl_dims-Metafeld? Nützlich vor einem Rollout, um Lücken zu finden, statt sie erst beim Label zu bemerken. Nur lesend, kein Shopify-Write.

{
  "variants_total": 320,          // alle Varianten im Shop
  "shipping_relevant": 300,       // davon mit Gewicht > 0 (versandrelevant)
  "with_dimensions": 240,         // davon mit gepflegtem karl_dims-Metafeld
  "without_dimensions": 60,
  "coverage_pct": 80.0,           // with_dimensions / shipping_relevant
  "missing_sample": [             // bis zu 10 Beispiele ohne Maße
    { "variant_id": "44012345678", "product": "Vase", "title": "Groß", "weight_g": 1200 }
  ],
  "weight_suspicious": [          // Verdacht auf kopiertes Gewicht (bis zu 20)
    { "product": "T-Shirt", "weight_g": 200, "variants": 5, "sizes": ["L","M","S","XL","XXL"],
      "reason": "identisches Gewicht (200 g) über 5 Größen" }
  ],
  "truncated": false             // true = Shop hat mehr Varianten als eine Abfrage abdeckt (4000)
}

weight_suspicious meldet Produkte, bei denen mehrere Größen exakt dasselbe Gewicht tragen (≥ 3 Varianten über ≥ 2 Größen) — ein typisches Zeichen dafür, dass ein Gewicht kopiert statt je Größe gepflegt wurde. Nur ein Hinweis, kein Fehler.

4. Idempotency

Label-Erstellung kostet Geld und löst Fulfillment aus — ein Netzwerk-Retry darf nie doppelt wirken:

5. Fehler-Kontrakt

Alle Fehler haben denselben Aufbau:

{ "error": { "code": "customs_validation_failed",
             "message": "HS-Code fehlt für Artikel X.",
             "message_en": "Missing HS code for item X.",
             "details": { } } }
HTTPcodeBedeutung
401unauthorizedAPI-Key fehlt, ungültig oder widerrufen
403feature_not_enabledShop hat kein Enterprise / api_access
404order_not_foundBestellung nicht gefunden
404draft_not_foundEntwurf nicht gefunden (oder schon gebucht)
404shipment_not_foundKeine Sendung mit dieser Tracking-Nummer in deinem Shop
400invalid_packagepackage (weight_g/length_cm/width_cm/height_cm > 0) fehlt
400invalid_recipientEmpfänger fehlt oder ist unvollständig (/api/v1/rates, /api/v1/labels; seit 1.3.20 auch /api/v1/orders/{order_id}/labels, wenn in der Lieferadresse der Bestellung ein Pflichtfeld fehlt) — fehlende Felder in details.missing
400invalid_itemsitems ist keine Liste oder eine Position ist ungültig (z. B. currency fehlt, compliance ist kein Objekt mit dem Text cpsc)
400variant_not_foundvariant_id gibt es in diesem Shop nicht
400idempotency_key_requiredIdempotency-Key-Header fehlt (bei /labels)
400invalid_idempotency_keyKey zu lang (> 200 Zeichen)
400invalid_requestcarrier/service fehlt oder ist kein Text, dry_run ist kein Wahrheitswert, oder declared_value/goods_value ist keine Zahl ab 0 (bei goods_value fehlt goods_currency); bei GET /api/v1/shipments: Datum nicht im Format JJJJ-MM-TT, from nach to, limit außerhalb 1–200 oder after nicht aus next_after
400service_unavailableDienst für diese Sendung nicht buchbar — details.available nennt die buchbaren Codes; der Idempotency-Key bleibt frei
409request_in_progressgleicher Idempotency-Key wird gerade bearbeitet
409draft_limit_reachedschon 50 Entwürfe — alte löschen
503shopify_unavailableProduktdaten aus Shopify gerade nicht ladbar (bei variant_id) — nichts gebucht, später erneut senden
422idempotency_key_conflictIdempotency-Key bereits für eine andere Bestellung genutzt — neuen Key verwenden
422customs_validation_failedZoll-Pflichtdaten (HS-Code, Ursprungsland, Warenbeschreibung, MRN/ABD) fehlen — siehe details; details.code zollangaben_fehlen, wenn nur die Beschreibung fehlt oder der Carrier-Baustein eine Lücke fand (KYTH.Karl setzt keine Ersatzwerte ein)
422customs_data_missingDrittland: per-Artikel Gewicht/Warenwert fehlt in Shopify
422packstation_nur_dhl_paketEmpfänger ist eine DHL Packstation oder DHL-Postfiliale in Deutschland — dorthin bucht KYTH.Karl nur DHL Paket (carrier: "dhl"); ein anderer Carrier ist gewählt oder DHL Paket im Shop nicht nutzbar. details: abholstelle, carrier, dhl_eingerichtet, erlaubt. Nichts gebucht, der Idempotency-Key bleibt frei (bei /labels)
422bundesstaat_fehltEmpfänger in den USA, in Kanada, Australien oder Mexiko ohne gültigen Bundesstaat bzw. Provinz (province_code). details: feld, land, grund (fehlt | ungueltig), wert. Nichts gebucht, der Idempotency-Key bleibt frei (bei /labels)
422packstation_postnummer_fehltDHL Packstation oder DHL-Postfiliale ohne Postnummer (6–10 Ziffern) bei carrier: "dhl" — bis 1.3.19 kam das als 400 carrier_error. details: feld (postnummer). Nichts gebucht, der Idempotency-Key bleibt frei (bei /labels)
422eircode_fehltFedEx nach Irland ohne Eircode in zip. details: feld (zip), land (IE), grund, wert. Nichts gebucht, der Idempotency-Key bleibt frei (bei /labels)
422eircode_ungueltigFedEx nach Irland, die Eircode hat nicht die Form „D02 X285“ (3 + 4 Zeichen). KYTH.Karl prüft nur die Form, nicht ob FedEx den Bezirk kennt. details wie bei eircode_fehlt, wert ist die Eingabe
422partial_shipment_unavailableTeilweise versendete Bestellung, deren Positionen keine Kennung tragen — KYTH.Karl könnte in Shopify nur die ganze Bestellung als versendet markieren; das Label in der App erstellen (seit 1.3.77). Nichts gebucht, der Idempotency-Key bleibt frei (bei /orders/{order_id}/labels)
429rate_limitedRate-Limit überschritten (120/min pro Shop)
500result_persist_failedLabel erstellt, aber Ergebnis nicht gespeichert — Bestellstatus prüfen, NICHT mit gleichem Key wiederholen
502carrier_errorCarrier-API-Fehler
503service_check_failedDienst gerade nicht prüfbar (Carrier nicht erreichbar) — nichts gebucht, später mit demselben Key erneut senden

6. Vollständiges Beispiel (curl)

KEY="karl_live_xxx"
BASE="https://karl.kyth.systems"

# 1) Offene Bestellungen
curl -s "$BASE/api/v1/orders?status=unfulfilled&limit=50" \
  -H "Authorization: Bearer $KEY"

# 2) Carrier-Optionen für Bestellung 1042
curl -s -X POST "$BASE/api/v1/orders/1042/rates" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"package":{"weight_g":2500,"length_cm":30,"width_cm":20,"height_cm":15}}'

# 3) Label erstellen + per Companion drucken — carrier + service UNVERÄNDERT aus Schritt 2
curl -s -X POST "$BASE/api/v1/orders/1042/labels" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d3f-uuid" \
  -d '{"carrier":"ups","service":"11","print":"companion",
       "package":{"weight_g":2500,"length_cm":30,"width_cm":20,"height_cm":15}}'

7. Hinweise

8. Änderungen der API