API-Integration

Öffentliche API für eigene Anbindungen — API-Schlüssel, Endpunkte mit Request- und Response-Beispielen, Katalog-Importe und Webhooks.

Mit der öffentlichen Vinolin-API kann dein eigenes System (Warenwirtschaft, Agentur, IT-Dienstleister) deine Weindaten programmatisch verwalten — Weine anlegen, aktualisieren, löschen, ganze Kataloge importieren und sich per Webhook benachrichtigen lassen.

Alles rund um die API findest du im Dashboard unter Einstellungen → Schnittstellen → API-Integration. Dort verwaltest du an einem Ort:

  • API-Schlüssel — Zugangsschlüssel erstellen und widerrufen
  • Webhooks — Benachrichtigungs-Endpunkte registrieren
  • API-Importe — Status deiner Katalog-Importe verfolgen

Grundlagen

  • Basis-URL: https://api.vinolin.com
  • Format: JSON (UTF-8), Zeitstempel als ISO 8601 in UTC (z. B. 2026-07-02T12:00:00.000Z)
  • Preise: immer in Euro-Cent als Ganzzahl — 1299 = 12,99 € (die Katalogdatei beim Bulk-Import akzeptiert alternativ auch price in Euro, siehe unten)
  • Authentifizierung: Jede Anfrage braucht einen API-Schlüssel im Header:
Authorization: Bearer vnln_<dein-schlüssel>

Ein widerrufener oder unbekannter Schlüssel erhält 401:

{ "error": "unauthorized" }
  • Rate-Limit: 300 Anfragen pro Minute je Schlüssel. Jede Antwort enthält die Header X-RateLimit-Limit, X-RateLimit-Remaining und X-RateLimit-Reset; bei Überschreitung kommt 429 rate_limited mit Retry-After (Sekunden). Ein Bulk-Import zählt als eine Anfrage — egal wie groß der Katalog ist.
  • Wein-ID: Ein einzelner Wein wird in der URL über seine Wein-ID (id) angesprochen — die serverseitig vergebene ID, die POST /wines, die Weinliste und jeder Import zurückgeben. Speichere sie zu deinem Produktdatensatz. Artikelnummer und GTIN/EAN bleiben Datenfelder (eindeutig je Shop), sind aber kein URL-Schlüssel mehr.

Fehlerformat

Alle Fehler nutzen denselben Umschlag:

{ "error": "<code>", "details": "<optionaler Kontext>" }
HTTPCodeBedeutung
400invalid_json / invalid_bodyBody kein gültiges JSON bzw. Schema-Validierung fehlgeschlagen (details enthält die Feldfehler)
400invalid_query / invalid_cursorUngültige Listen-Parameter
400invalid_file_url / file_not_uploadedKatalogdatei-Referenz falsch oder Upload fehlt
401unauthorizedSchlüssel fehlt, ist unbekannt oder widerrufen
404not_foundKein Wein/Job/Webhook mit dieser ID in deinem Shop
409wine_existsDie gesendete articleNumber/productUrl/GTIN/wbanArtnr gehört bereits einem anderen Wein deines Shops (details nennt dessen id und in matchedBy das betroffene Feld)
409webhook_url_existsEin aktiver Webhook mit dieser URL existiert bereits
409job_not_running / job_not_awaiting_confirmationConfirm auf einen Job, der es nicht braucht
422missing_fields / upsert_failedVerarbeitungsfehler; details enthält Kontext
429rate_limitedRate-Limit überschritten — Retry-After beachten

Die Wein-Ressource

Das Format, das GET zurückgibt und POST/PUT/PATCH akzeptieren:

FeldTypPflicht bei POST/PUTHinweis
articleNumberstring | nullneinDeine SKU, eindeutig je Shop (409 wine_exists)
namestringjaProduktname
wineTypeenumjaSTILL, SPARKLING, SEMI_SPARKLING, MULLED_WINE, PARTIALLY_FERMENTED_GRAPE_MUST
colorenum | nullneinWHITE, RED, ROSE, ORANGE
vintageinteger | nullneinJahrgang, z. B. 2022
alcoholContentnumber | nullneinVolumenprozent, z. B. 11.5
residualSugarobject | nullnein{ "amount": 8.2, "unit": "g/L" } (auch "g/100g")
residualSugarTermenum | nullneinDRY, MEDIUM_DRY, MEDIUM_SWEET, SWEET, BRUT_NATURE, EXTRA_BRUT, BRUT, EXTRA_DRY, DEMI_SEC, DOUX
acidContentobject | nullneinwie residualSugar
bottleSizenumber | nullneinLiter, z. B. 0.75
priceinteger | nullneinCent, 1299 = 12,99 €
descriptionstring | nullneinFreitext, HTML wird bereinigt
imageUrlstring | nullneinDeine Bild-URL — wird asynchron verarbeitet (s. u.)
productUrlstring | nullneinLink zu deiner Produktseite
gtin / eanstring | nullneinNur Ziffern nach Normalisierung
wbanArtnrstring | nullneinLieferanten-Artikelnummer (z. B. Hawesko wban_artnr), eindeutig je Shop (409 wine_exists). Wird getrennt von GTIN/EAN gespeichert und erscheint in Antworten als eigenes Feld — nie im gtins-Array
vegan / organicboolean | nullnein
containsSulfitesboolean | nullnein„enthält Sulfite“ — null wird als false gespeichert
shortDescriptionstring | nullneinKurzbeschreibung (z. B. Weinstil)
inStockbooleannein (Standard true)
wineryNamestring | nullneinWird unscharf gegen bestehende Weingüter abgeglichen
grapeVarietyarray | nullnein[{ "name": "Riesling", "percentage": 100 }] — wenn eine Rebsorte einen Prozentwert hat, brauchen alle einen und die Summe muss 100 sein

Zusätzlich nur in Antworten: id (Server-ID), gtins (alle GTIN/EAN-Identifier des Weins als Array — ersetzt in Antworten die einzelnen Felder gtin/ean; wbanArtnr ist darin nicht enthalten, sondern bleibt ein eigenes Feld), createdAt, updatedAt. Bei GET kommen residualSugar/acidContent als einfache g/L-Zahl zurück (normalisiert), nicht als Objekt; grapeVariety wird beim Schreiben angenommen, aber nicht zurückgegeben.

Bilder werden asynchron verarbeitet

Vinolin lädt dein Bild herunter, entfernt den Hintergrund und hostet das Ergebnis selbst — nach der API-Antwort. Antworten mit neuem Bild tragen "imageStatus": "processing"; das fertige Bild erscheint typischerweise nach Sekunden bis wenigen Minuten. Dieselbe unveränderte Bild-URL erneut zu senden ist erkannt und löst keine Neuverarbeitung aus — imageUrl darf also bei jedem Sync dabei sein. Ein fehlgeschlagener Bild-Download macht den Wein-Schreibvorgang nie kaputt.

Einzelne Weine

Für laufende Aktualisierungen zwischen den Voll-Synchronisationen (Preis, Bestand, einzelne Produkte).

Wein anlegen — POST /api/v1/wines

Legt einen Wein an; der Server vergibt die Wein-ID und gibt sie zurück — speichere sie, sie ist der Pfad-Schlüssel für alle weiteren Aufrufe. Gehört eine gesendete articleNumber/productUrl/GTIN bereits einem anderen Wein deines Shops, kommt 409 wine_exists (mit dessen id in details).

POST /api/v1/wines
Authorization: Bearer vnln_...
Content-Type: application/json

{
  "articleNumber": "X-1",
  "name": "Riesling Kabinett 2022",
  "wineType": "STILL",
  "color": "WHITE",
  "vintage": 2022,
  "alcoholContent": 11.5,
  "residualSugar": { "amount": 8.2, "unit": "g/L" },
  "residualSugarTerm": "DRY",
  "bottleSize": 0.75,
  "price": 1299,
  "gtin": "4007524264624",
  "wbanArtnr": "D12345",
  "imageUrl": "https://example.com/bottle.jpg",
  "productUrl": "https://example.com/p/x-1",
  "vegan": true,
  "organic": false,
  "containsSulfites": true,
  "inStock": true,
  "wineryName": "Weingut Mustermann",
  "grapeVariety": [{ "name": "Riesling", "percentage": 100 }]
}

Antwort — 201 Created:

{ "id": "ckq1abc...", "articleNumber": "X-1", "imageStatus": "processing" }

Wein vollständig aktualisieren — PUT /api/v1/wines/{id}

Ersetzt die vollständige Darstellung eines bestehenden Weins (Body wie bei POST); unbekannte ID → 404. Dieselbe Anfrage doppelt zu senden ist immer sicher. Weggelassene optionale Felder werden auf null gesetzt (Voll-Ersetzung) — außer imageUrl: Weglassen oder null behält das vorhandene Bild.

Antwort — 200 OK:

{ "id": "ckq1abc...", "articleNumber": "X-1", "imageStatus": "processing" }

Einzelnen Wein abrufen — GET /api/v1/wines/{id}

Antwort — 200 OK: die vollständige Wein-Ressource (Felder siehe oben) inklusive id, gtins, createdAt, updatedAt. Sonst 404 not_found.

{
  "id": "ckq1abc...",
  "articleNumber": "X-1",
  "name": "Riesling Kabinett 2022",
  "wineType": "STILL",
  "color": "WHITE",
  "vintage": 2022,
  "alcoholContent": 11.5,
  "residualSugar": 8.2,
  "residualSugarTerm": "DRY",
  "acidContent": null,
  "bottleSize": 0.75,
  "price": 1299,
  "description": null,
  "imageUrl": "https://cdn.vinolin.com/…",
  "productUrl": "https://example.com/p/x-1",
  "gtins": ["4007524264624"],
  "wbanArtnr": "D12345",
  "vegan": true,
  "organic": false,
  "containsSulfites": true,
  "shortDescription": null,
  "inStock": true,
  "wineryName": "Weingut Mustermann",
  "createdAt": "2026-07-02T12:00:00.000Z",
  "updatedAt": "2026-07-02T12:00:00.000Z"
}

Weinliste abrufen — GET /api/v1/wines

Zum Abgleich deines Katalogs. Sortiert nach updatedAt aufsteigend, stabil über Seiten hinweg.

ParameterTypStandardHinweis
limit1–20050Seitengröße
cursorstringOpak; aus nextCursor der vorherigen Seite
updatedSinceISO 8601Nur Weine, die seitdem geändert wurden (Delta-Sync)

Antwort — 200 OK:

{
  "items": [ { "…": "Wein-Ressource wie oben" } ],
  "nextCursor": "eyJ1IjoiMjAyNi0w..."
}

nextCursor ist auf der letzten Seite null. Für Delta-Syncs: merke dir das neueste updatedAt und übergib es beim nächsten Abruf als updatedSince.

Einzelne Felder ändern — PATCH /api/v1/wines/{id}

Ändert nur die im Body enthaltenen Felder; alles Weggelassene bleibt unberührt. articleNumber kann per PATCH nicht geändert werden (dafür PUT nutzen).

PATCH /api/v1/wines/ckq1abc...
Content-Type: application/json

{ "price": 1499, "inStock": false }

Antwort — 200 OK:

{ "id": "ckq1abc...", "articleNumber": "X-1" }

Sonderfälle: {"imageUrl": "https://…"} stößt die Bildverarbeitung an (Antwort enthält dann imageStatus: "processing"); {"imageUrl": null} entfernt das Bild; null bei anderen Feldern leert das Feld.

Wein löschen — DELETE /api/v1/wines/{id}

Der Wein verschwindet sofort aus dem Shop (Soft-Delete). Dieselbe articleNumber kann danach neu angelegt werden.

Antwort — 204 No Content (leerer Body).

Katalog-Import (Bulk)

Für den Erstimport und regelmäßige Voll-Synchronisationen — der richtige Weg ab ein paar hundert Weinen. Der Ablauf:

  1. POST /api/v1/catalog/upload-url — signierte Upload-URL anfordern
  2. Katalog-JSON per PUT auf diese URL hochladen (innerhalb von 15 Minuten)
  3. POST /api/v1/catalog/imports — Import starten, liefert eine Job-ID
  4. GET /api/v1/jobs/{id} abfragen oder per Webhook benachrichtigen lassen

Die Katalogdatei

Ein einzelnes JSON-Objekt mit einem products-Array. Die Felder entsprechen der Wein-Ressource. Der Preis wird wie bei den Einzel-Endpunkten als priceCents in Euro-Cent gesendet (z. B. 1299 = 12,99 €). Alternativ akzeptiert die Katalogdatei auch price in Euro (z. B. 12.99) — sind beide Felder gesetzt, gewinnt priceCents. Produkte, die die Validierung nicht bestehen, werden übersprungen und in invalidCount gezählt.

{
  "products": [
    {
      "articleNumber": "X-1",
      "name": "Riesling Kabinett 2022",
      "wineType": "STILL",
      "color": "WHITE",
      "vintage": 2022,
      "priceCents": 1299,
      "inStock": true,
      "imageUrl": "https://example.com/bottle.jpg",
      "productUrl": "https://example.com/p/x-1",
      "gtin": "4007524264624",
      "wbanArtnr": "D12345",
      "containsSulfites": true,
      "wineryName": "Weingut Mustermann",
      "grapeVariety": [{ "name": "Riesling", "percentage": 100 }]
    }
  ]
}

Bestehende Weine werden über productUrl, dann articleNumber, dann GTIN/EAN wiedererkannt — ein erneuter Voll-Sync aktualisiert und erzeugt nie Duplikate. wbanArtnr dient dabei nicht der Wiedererkennung; es wird als zusätzlicher Identifier am Wein gespeichert. Fehlt das Feld in der Katalogdatei, bleibt ein bereits gespeicherter Wert unverändert (ältere Kataloge ohne das Feld löschen also nichts).

Upload-URL anfordern — POST /api/v1/catalog/upload-url

Antwort — 201 Created:

{
  "uploadUrl": "https://storage.googleapis.com/…signiert…",
  "fileUrl": "gs://…/api-import/2f6f….json",
  "expiresAt": "2026-07-02T13:15:00.000Z",
  "method": "PUT",
  "requiredHeaders": { "Content-Type": "application/json" }
}

Dann die Datei hochladen:

curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/json" --data-binary @catalog.json

Import starten — POST /api/v1/catalog/imports

{ "fileUrl": "gs://…/api-import/2f6f….json", "mode": "replace" }
  • mode: "replace" (Standard) — Voll-Sync: Weine, die nicht in der Datei stehen, werden danach entfernt. Für nächtliche Syncs.
  • mode: "append" — nur anlegen/aktualisieren, nichts wird gelöscht.

Antwort — 202 Accepted (der Job, direkt nach dem Start):

{
  "id": "6a1c3f…",
  "type": "catalog-import",
  "status": "pending",
  "mode": "replace",
  "savedCount": null,
  "invalidCount": null,
  "error": null,
  "result": null,
  "createdAt": "2026-07-02T12:00:00.000Z",
  "completedAt": null
}

Importe eines Shops laufen nacheinander — ein zweiter Job wartet, bis der erste fertig ist.

Job-Status abfragen — GET /api/v1/jobs/{id}

Liefert den Job im selben Format wie oben. status ist pending, running, awaiting_confirmation, completed oder failed; bei Erfolg sind savedCount und invalidCount gefüllt:

{
  "id": "6a1c3f…",
  "type": "catalog-import",
  "status": "completed",
  "mode": "replace",
  "savedCount": 19874,
  "invalidCount": 3,
  "error": null,
  "result": null,
  "createdAt": "2026-07-02T12:00:00.000Z",
  "completedAt": "2026-07-02T14:00:00.000Z"
}

Letzte Jobs auflisten — GET /api/v1/jobs

Antwort — 200 OK: { "items": [ …Jobs wie oben… ] }, neueste zuerst. Parameter: limit (1–100, Standard 20).

Sicherheitsprüfung — POST /api/v1/jobs/{id}/confirm

Schutz vor versehentlichem Löschen

Würde ein Replace-Import mehr als 15 % deiner bestehenden Weine entfernen, pausiert er mit status: "awaiting_confirmation", statt zu löschen. Ist die Verkleinerung beabsichtigt, bestätige den Job — per API oder direkt im Dashboard über „Trotzdem importieren“ in der Karte API-Importe.

POST /api/v1/jobs/6a1c3f…/confirm
Authorization: Bearer vnln_...

Antwort — 202 Accepted — der Import läuft weiter. Unbestätigte Jobs schlagen nach 7 Tagen fehl und blockieren bis dahin nachfolgende Importe deines Shops.

Der erste Import eines großen Katalogs kann mehrere Stunden dauern, weil jedes Produktbild einmal verarbeitet wird. Folge-Syncs sind deutlich schneller — unveränderte Bilder werden übersprungen.

Webhooks

Statt den Job-Status abzufragen, kannst du dich benachrichtigen lassen: Nach Abschluss eines Imports schickt Vinolin ein job.completed-Ereignis an deine HTTPS-Adresse. Webhooks lassen sich auch ohne Code im Dashboard registrieren: API-Integration → Webhooks → „Webhook registrieren“.

Webhook registrieren — POST /api/v1/webhooks

{ "url": "https://partner.example.com/vinolin/webhook", "events": ["job.completed"] }

Antwort — 201 Created:

{
  "id": "wh_…",
  "url": "https://partner.example.com/vinolin/webhook",
  "events": ["job.completed"],
  "description": null,
  "secret": "whsec_…",
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "createdAt": "2026-07-02T12:00:00.000Z"
}

Das Secret gibt es nur einmal

Das secret (whsec_…) wird nur in dieser Antwort angezeigt — speichere es sicher. Du brauchst es, um die Signatur eingehender Benachrichtigungen zu prüfen. Ist es verloren, widerrufe den Webhook und registriere die URL neu — du erhältst ein frisches Secret.

Eine bereits registrierte aktive URL liefert 409 webhook_url_exists.

Webhooks auflisten — GET /api/v1/webhooks

Antwort — 200 OK (ohne Secrets, mit Zustell-Gesundheit):

{
  "items": [
    {
      "id": "wh_…",
      "url": "https://partner.example.com/vinolin/webhook",
      "events": ["job.completed"],
      "description": null,
      "lastSuccessAt": "2026-07-02T14:00:05.000Z",
      "lastFailureAt": null,
      "createdAt": "2026-07-02T12:00:00.000Z"
    }
  ]
}

Webhook widerrufen — DELETE /api/v1/webhooks/{id}

Antwort — 204 No Content. Ab sofort werden keine Ereignisse mehr zugestellt.

Zustellung: das Ereignis-Payload

job.completed wird als POST mit diesem Body an deine URL geschickt:

{
  "id": "evt_…",
  "type": "job.completed",
  "createdAt": "2026-07-02T14:00:00.000Z",
  "data": {
    "job": {
      "id": "6a1c3f…",
      "type": "catalog-import",
      "status": "completed",
      "mode": "replace",
      "savedCount": 19874,
      "invalidCount": 3,
      "error": null,
      "createdAt": "2026-07-02T12:00:00.000Z",
      "completedAt": "2026-07-02T14:00:00.000Z"
    }
  }
}

Dein Endpunkt muss innerhalb von 10 Sekunden mit einem 2xx-Status antworten. Fehlgeschlagene Zustellungen werden mit wachsendem Abstand bis zu 4-mal wiederholt.

Signatur prüfen

Jede Zustellung trägt einen Signatur-Header, damit dein System sicher sein kann, dass die Nachricht von Vinolin stammt und nicht verändert wurde:

X-Vinolin-Signature: t=1751464800,v1=5257a869e7…

Prüfung: HMAC-SHA256(secret, "<t>.<roher Request-Body>") berechnen und zeitkonstant mit v1 vergleichen; Nachrichten ablehnen, deren t älter als 5 Minuten ist (Schutz vor Replay).

const [tPart, vPart] = header.split(",");
const t = tPart.slice(2), v1 = vPart.slice(3);
const expected = crypto
  .createHmac("sha256", secret)
  .update(`${t}.${rawBody}`)
  .digest("hex");
// zeitkonstant expected mit v1 vergleichen und |jetzt - t| < 300 s prüfen

API-Schlüssel verwalten

  1. Öffne Einstellungen → Schnittstellen → API-Integration und klicke auf „Neuer Schlüssel“.
  2. Vergib einen Namen (z. B. „Warenwirtschaft Müller GmbH“).
  3. Der Schlüssel (vnln_…) wird einmalig vollständig angezeigt — kopiere ihn sofort an einen sicheren Ort.
  4. Danach siehst du nur noch Name, Präfix und „Zuletzt verwendet“.
  5. Mit „Widerrufen“ wird ein Schlüssel sofort ungültig; widerrufene Schlüssel verschwinden aus der Liste.

API-Schlüssel sind wie Schlüssel zum Büro

Wer den Schlüssel hat, kann auf deine Shop-Daten zugreifen. Teile ihn nur mit vertrauenswürdigen Dienstleistern und widerrufe ihn, sobald er nicht mehr gebraucht wird. Ein verlorener Schlüssel lässt sich nicht wiederherstellen — widerrufe ihn und erstelle einen neuen.

Auf dieser Seite