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

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", "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).

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": "STANDARD",
  "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": "STANDARD",
  "cost": 6.20, "currency": "EUR", "label_base64": "JVBERi0…",
  "printed": true, "fulfilled": true }

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

Trockenlauf (dry_run): Setz "dry_run": true im Body, 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": "STANDARD", "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": "STANDARD",
  "checks": { "package": true, "customs_item_data": true, "customs_validation": true, "service_available": true },
  "errors": [],                          // bei valid:false z.B. {"code":"customs_validation_failed", …}
  "package_used": { "weight_g": 700, "length_cm": 24, "width_cm": 18, "height_cm": 17, "source": "variant_metafield" },
  "estimated_cost": 6.20, "currency": "EUR" }

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": "STANDARD",
  "cost": 6.20, "currency": "EUR", "printed": true, "fulfilled": true }

Unbekannter Key → 404 idempotency_key_not_found.

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
400invalid_packagepackage (weight_g/length_cm/width_cm/height_cm > 0) fehlt
400idempotency_key_requiredIdempotency-Key-Header fehlt (bei /labels)
400invalid_idempotency_keyKey zu lang (> 200 Zeichen)
400invalid_requestcarrier/service fehlt
409request_in_progressgleicher Idempotency-Key wird gerade bearbeitet
422idempotency_key_conflictIdempotency-Key bereits für eine andere Bestellung genutzt — neuen Key verwenden
422customs_validation_failedZoll-Pflichtdaten (HS-Code/MRN/ABD) fehlen — siehe details
422customs_data_missingDrittland: per-Artikel Gewicht/Warenwert fehlt in Shopify
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

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 mit UPS erstellen + per Companion drucken
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":"STANDARD","print":"companion",
       "package":{"weight_g":2500,"length_cm":30,"width_cm":20,"height_cm":15}}'

7. Hinweise