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 auchpricein 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-RemainingundX-RateLimit-Reset; bei Überschreitung kommt429 rate_limitedmitRetry-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, diePOST /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>" }| HTTP | Code | Bedeutung |
|---|---|---|
| 400 | invalid_json / invalid_body | Body kein gültiges JSON bzw. Schema-Validierung fehlgeschlagen (details enthält die Feldfehler) |
| 400 | invalid_query / invalid_cursor | Ungültige Listen-Parameter |
| 400 | invalid_file_url / file_not_uploaded | Katalogdatei-Referenz falsch oder Upload fehlt |
| 401 | unauthorized | Schlüssel fehlt, ist unbekannt oder widerrufen |
| 404 | not_found | Kein Wein/Job/Webhook mit dieser ID in deinem Shop |
| 409 | wine_exists | Die gesendete articleNumber/productUrl/GTIN/wbanArtnr gehört bereits einem anderen Wein deines Shops (details nennt dessen id und in matchedBy das betroffene Feld) |
| 409 | webhook_url_exists | Ein aktiver Webhook mit dieser URL existiert bereits |
| 409 | job_not_running / job_not_awaiting_confirmation | Confirm auf einen Job, der es nicht braucht |
| 422 | missing_fields / upsert_failed | Verarbeitungsfehler; details enthält Kontext |
| 429 | rate_limited | Rate-Limit überschritten — Retry-After beachten |
Die Wein-Ressource
Das Format, das GET zurückgibt und POST/PUT/PATCH akzeptieren:
| Feld | Typ | Pflicht bei POST/PUT | Hinweis |
|---|---|---|---|
articleNumber | string | null | nein | Deine SKU, eindeutig je Shop (409 wine_exists) |
name | string | ja | Produktname |
wineType | enum | ja | STILL, SPARKLING, SEMI_SPARKLING, MULLED_WINE, PARTIALLY_FERMENTED_GRAPE_MUST |
color | enum | null | nein | WHITE, RED, ROSE, ORANGE |
vintage | integer | null | nein | Jahrgang, z. B. 2022 |
alcoholContent | number | null | nein | Volumenprozent, z. B. 11.5 |
residualSugar | object | null | nein | { "amount": 8.2, "unit": "g/L" } (auch "g/100g") |
residualSugarTerm | enum | null | nein | DRY, MEDIUM_DRY, MEDIUM_SWEET, SWEET, BRUT_NATURE, EXTRA_BRUT, BRUT, EXTRA_DRY, DEMI_SEC, DOUX |
acidContent | object | null | nein | wie residualSugar |
bottleSize | number | null | nein | Liter, z. B. 0.75 |
price | integer | null | nein | Cent, 1299 = 12,99 € |
description | string | null | nein | Freitext, HTML wird bereinigt |
imageUrl | string | null | nein | Deine Bild-URL — wird asynchron verarbeitet (s. u.) |
productUrl | string | null | nein | Link zu deiner Produktseite |
gtin / ean | string | null | nein | Nur Ziffern nach Normalisierung |
wbanArtnr | string | null | nein | Lieferanten-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 / organic | boolean | null | nein | — |
containsSulfites | boolean | null | nein | „enthält Sulfite“ — null wird als false gespeichert |
shortDescription | string | null | nein | Kurzbeschreibung (z. B. Weinstil) |
inStock | boolean | nein (Standard true) | — |
wineryName | string | null | nein | Wird unscharf gegen bestehende Weingüter abgeglichen |
grapeVariety | array | null | nein | [{ "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.
| Parameter | Typ | Standard | Hinweis |
|---|---|---|---|
limit | 1–200 | 50 | Seitengröße |
cursor | string | — | Opak; aus nextCursor der vorherigen Seite |
updatedSince | ISO 8601 | — | Nur 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:
POST /api/v1/catalog/upload-url— signierte Upload-URL anfordern- Katalog-JSON per
PUTauf diese URL hochladen (innerhalb von 15 Minuten) POST /api/v1/catalog/imports— Import starten, liefert eine Job-IDGET /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.jsonImport 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üfenAPI-Schlüssel verwalten
- Öffne Einstellungen → Schnittstellen → API-Integration und klicke auf „Neuer Schlüssel“.
- Vergib einen Namen (z. B. „Warenwirtschaft Müller GmbH“).
- Der Schlüssel (
vnln_…) wird einmalig vollständig angezeigt — kopiere ihn sofort an einen sicheren Ort. - Danach siehst du nur noch Name, Präfix und „Zuletzt verwendet“.
- 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.