Datum, Zahlen, Zusatzfelder: die Datentyp-Fallen der weclapp API
Die lästigsten Fehler in einer API-Integration sind die stillen. Kein 400, keine Fehlermeldung, nur ein Lieferdatum, das in Slack einen Tag zu früh erscheint, ein Preis, den weclapp ablehnt, oder ein Zusatzfeld, das nie einen Wert bekommt. In diesem Artikel findest du die Datentyp-Fallen der weclapp API an einer Stelle, jeweils mit der Regel, die sie entschärft.
Die Grundregeln stehen im JSON-Abschnitt der offiziellen API-Doku von weclapp. Die Fallen dahinter kennen wir aus unseren Integrationen im Dauerbetrieb: Fulfillment 4 weclapp, wals.pro AI 4 weclapp und n8n-Flows für Kunden. Christian Lanig (weclapp) hat am Community Day 2026 empfohlen, beim Schreiben nur Änderungen zu übertragen und unnötige Updates zu vermeiden. Saubere Datentypen sind dafür die Voraussetzung.
Datumsfelder sind Epoch-Millisekunden
Die Doku beschreibt Datums- und Zeitwerte als Millisekunden seit 1970-01-01T00:00:00Z, als JSON-Zahl. In der Spec sind alle Datumsfelder format: timestamp mit type: integer. Ein Datumsformat ohne Zeitanteil gibt es nicht. Ein Auftrag sieht so aus:
{
"orderDate": 1783634400000,
"plannedDeliveryDate": 1783720800000
}
orderDate ist der 10.07.2026, plannedDeliveryDate der 11.07.2026. Die Zahlen verraten es nicht, und genau das ist die Falle. Reine Datumsfelder wie orderDate, plannedDeliveryDate, dueDate oder der dateValue eines Zusatzfelds stehen auf dem Zeitpunkt von Berlin-Mitternacht:
| Wert | als UTC gelesen | in Berlin gelesen |
|---|---|---|
1783634400000 |
09.07.2026, 22:00 Uhr | 10.07.2026, 00:00 Uhr (Sommerzeit, +02:00) |
1798066800000 |
23.12.2026, 23:00 Uhr | 24.12.2026, 00:00 Uhr (Winterzeit, +01:00) |
Die Doku nennt diese Berlin-Mitternacht nicht, die Daten zeigen sie aber eindeutig. Ein Auftrag vom 7. Oktober 2026 trägt das orderDate 1791324000000, in UTC der 6. Oktober um 22:00 Uhr. Einer vom 27. Februar 2026 trägt 1772146800000, in UTC der 26. Februar um 23:00 Uhr. Zeitstempel wie createdDate und lastModifiedDate sind dagegen echte Zeitpunkte mit Stunde, Minute und Millisekunde. Das zeigt schon das Filterbeispiel der Doku, createdDate-gt=1398436281262, also der 25.04.2014 um 14:31:21 Uhr UTC.
Der Tagesversatz: warum der 10. Juli zum 9. Juli wird
Wer den Wert als UTC rendert, bekommt den Vortag. Der Code läuft, nichts schlägt fehl, und nur ein Mensch, der das Ergebnis mit weclapp vergleicht, bemerkt es. Auf einem Rechner mit UTC-Zeitzone sieht das so aus:
const ms = 1783634400000; // 10.07.2026 in weclapp
new Date(ms).toISOString().split('T')[0]; // "2026-07-09" falsch
new Date(ms).toLocaleDateString('de-DE'); // "9.7.2026" falsch, nutzt die Prozess-Zeitzone
new Date(ms).getDate(); // 9 falsch
new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }); // "2026-07-10" richtig

Die Regel lautet: Den Kalendertag eines weclapp-Werts bildest du immer über eine echte Zeitzone, nie über einen festen Offset. Berlin liegt im Sommer bei +02:00 und im Winter bei +01:00, ein fester Abzug von zwei Stunden ist in der Hälfte des Jahres falsch. In Python erledigt ZoneInfo das:
from datetime import datetime
from zoneinfo import ZoneInfo
BERLIN = ZoneInfo("Europe/Berlin")
day = datetime.fromtimestamp(ms / 1000, BERLIN).date() # 2026-07-10
Besonders tückisch sind Monatsgrenzen. Der 1. August 2026 ist in weclapp 1785535200000, in UTC gelesen der 31. Juli, 22:00 Uhr. Wer Umsätze oder Belege nach Monaten bucketet und dafür UTC verwendet, rechnet den ersten Tag jedes Monats dem Vormonat zu.
Datum schreiben: Berlin-Mitternacht statt UTC-Mitternacht
Beim Schreiben gilt dasselbe in umgekehrter Richtung. Für den 10.07.2026 brauchst du den Zeitpunkt von Berlin-Mitternacht, nicht Date.UTC(2026, 6, 10). Naheliegend ist, den Offset von 12 Uhr mittags des Tages zu nehmen. Das ist an den beiden Umstellungstagen im Jahr (2026: 29.03. und 25.10.) um eine Stunde falsch, weil dort Mitternacht und Mittag in verschiedenen Offsets liegen. Der folgende Helfer prüft die beiden möglichen Kandidaten direkt:
const BERLIN = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
function berlinParts(ms) {
const p = Object.fromEntries(BERLIN.formatToParts(ms).map((x) => [x.type, x.value]));
return { day: `${p.year}-${p.month}-${p.day}`, time: `${p.hour}:${p.minute}` };
}
// Berlin-Mitternacht (Epoch-ms) für einen Kalendertag "YYYY-MM-DD", auch an Umstellungstagen
function berlinMidnightMs(day) {
const [y, m, d] = day.split('-').map(Number);
const base = Date.UTC(y, m - 1, d);
for (const offsetH of [1, 2]) { // Winterzeit +1, Sommerzeit +2
const candidate = base - offsetH * 3600_000;
const p = berlinParts(candidate);
if (p.day === day && p.time === '00:00') return candidate;
}
throw new Error(`Keine Berlin-Mitternacht für ${day}`);
}
// berlinMidnightMs('2026-07-10') === 1783634400000
In Python genügt int(datetime(2026, 7, 10, tzinfo=BERLIN).timestamp() * 1000). Geschrieben wird dann mit dem üblichen PUT:
PUT https://<dein-mandant>.weclapp.com/webapp/api/v2/purchaseOrder/id/<bestell-id>?ignoreMissingProperties=true
AuthenticationToken: <dein-token>
Content-Type: application/json
{ "version": "<aktuelle-version>", "plannedDeliveryDate": 1783634400000 }
Mit ignoreMissingProperties=true änderst du nur die Felder im Request, version aktiviert laut Doku das optimistische Locking und schützt so vor parallelen Änderungen. Danach liest du den Datensatz nach und vergleichst den Kalendertag in Berlin mit deiner Eingabe.
Datumsfilter: Epoch-Millisekunden und halboffene Intervalle
Die Beispiele der Doku filtern mit Epoch-Millisekunden, etwa createdDate-gt=1398436281262. Für einen Tag brauchst du zwei Grenzen. Nimm ge für den Beginn und lt für den Beginn des Folgetags, nicht le mit 23:59:59:
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/salesOrder?orderDate-ge=1783634400000&orderDate-lt=1783720800000&properties=id,orderNumber,orderDate
AuthenticationToken: <dein-token>
Der Folgetag ist die Berlin-Mitternacht des nächsten Kalendertags, nicht ein Aufschlag von 86.400.000 Millisekunden. Am 29.03.2026 hat der Tag in Berlin 23 Stunden, am 25.10.2026 hat er 25. Dasselbe Muster gilt für Monate. Der Juli 2026 ist orderDate-ge=1782856800000 und orderDate-lt=1785535200000. Mit /count statt der Liste prüfst du die Menge, bevor du etwas abrufst. Die Parameterwerte stehen in diesem Artikel unkodiert, damit man sie lesen kann; dein HTTP-Client kodiert sie beim Senden.
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/salesOrder/count?orderDate-ge=1782856800000&orderDate-lt=1785535200000
AuthenticationToken: <dein-token>
Daneben gibt es den Filter-Ausdruck filter= (Beta). Dort kannst du ein Datum als Zeitpunkt in ISO-8601 schreiben, die Zeitzone ist laut Doku Pflicht, oder eine ganze Zahl als Epoch-Millisekunden:
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/salesOrder?filter=orderDate >= "2026-07-10T00:00:00+02:00"&properties=id,orderNumber,orderDate
AuthenticationToken: <dein-token>
Der Offset gehört zum Tag: +02:00 im Sommer, +01:00 im Winter. Mit Z statt des Offsets würdest du den ersten Tag verlieren, denn Berlin-Mitternacht liegt zwei Stunden vor 00:00 Uhr UTC.
ISO-Literale beschreibt die Doku nur für den filter=-Ausdruck, alle Beispiele für Property-Filter arbeiten mit Epoch-Millisekunden. Daraus ergibt sich eine einfache Regel: Property-Filter bekommen Epoch-Millisekunden, Filter-Ausdrücke bekommen einen Zeitpunkt mit Offset oder eine Zahl. Und weil weclapp unbekannte oder nicht filterbare Properties ohne Fehlermeldung ignoriert, prüfst du jede Datumsgrenze mit einem Gegenzähler.
Die n8n-Falle: settings.timezone wirkt nicht im Code-Node
In n8n gibt es die Workflow-Einstellung settings.timezone, etwa Europe/Berlin. Sie steuert nur n8n-eigene Luxon-Ausdrücke wie $now und $today sowie Schedule-Trigger. Ein nacktes new Date() im Code-Node nutzt dagegen die Zeitzone des Server-Prozesses. Läuft der in UTC, sieht der Workflow „auf Berlin eingestellt“ aus und formatiert trotzdem in UTC. Welche Zeitzone deine Instanz hat, verrät dir Intl.DateTimeFormat().resolvedOptions().timeZone in einem Code-Node.
Der Fehler hat vier Gesichter, alle mit derselben Lösung:
-
toLocaleDateString('de-DE')ohnetimeZone toISOString().split('T')[0]-
getDate(),getUTCDate()undgetMonth() -
new Date(y, m, d)für Monatsgrenzen
Die Lösung ist immer dieselbe: timeZone: 'Europe/Berlin' ausdrücklich mitgeben, unabhängig von settings.timezone.
// n8n Code-Node, "Run Once for All Items"
const berlinDay = (ms) =>
new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' });
return $input.all().map((item) => ({
json: { ...item.json, orderDay: berlinDay(item.json.orderDate) },
}));
Dezimalzahlen sind Strings
Die meisten Zahlen in weclapp, etwa Mengen und Preise, sind Dezimalzahlen mit fester Genauigkeit. weclapp serialisiert sie laut Doku als JSON-Strings, damit beim Parsen mit Gleitkommazahlen keine Genauigkeit verloren geht, und immer mit Punkt als Dezimalzeichen. Das in der Spec häufigste Muster (142 Feldeinträge, etwa unitPrice) ist ^-?([0-9]{1,13})([.][0-9]{1,5})?$: bis zu 13 Vorkomma- und 5 Nachkommastellen. Das sind bis zu 18 Ziffern, mehr, als eine Gleitkommazahl zuverlässig trägt.
{ "unitPrice": "19.90", "quantity": "2.5" }
Daraus ergeben sich drei Regeln.
Senden: runden und als String mit Punkt schicken. Die Doku erlaubt, eine Zahl zu senden, wo ein String erwartet wird. Berechnete Werte tragen aber Gleitkomma-Artefakte. Ein Warenwert wie 155.68998629999996 für declaredValueAmount hat 14 Nachkommastellen, das Muster des Felds erlaubt fünf. Ein kleiner Helfer bringt den Wert vor dem Senden in Form:
const toDecimal = (value, digits = 2) =>
(Math.round((value + Number.EPSILON) * 10 ** digits) / 10 ** digits).toFixed(digits);
toDecimal(155.68998629999996); // "155.69"
Formatiere nie mit toLocaleString('de-DE'). Das erzeugt "1.234,5", und weclapp erwartet "1234.5".
Rechnen: nicht mit Gleitkomma summieren. 0.1 + 0.2 ergibt in JavaScript 0.30000000000000004. Für Summen über viele Positionen rechnest du in ganzen Cent oder mit einer Dezimal-Bibliothek und rundest erst am Ende.
IDs bleiben Strings. "id": "4711" liest du als Text und wandelst es nicht in eine Zahl. Die Spec typisiert auch attributeDefinitionId als String. Vergleichst du IDs, dann immer als String, damit "4711" und 4711 nicht aneinander vorbeilaufen.

Leere Strings werden zu null
Ein leerer String, auch einer aus Leerzeichen, wird von weclapp immer als null gelesen. Einen Wert „leer, aber gesetzt“ gibt es nicht. Ohne ignoreMissingProperties=true gilt außerdem jede Property, die im Request-JSON fehlt, als null. Ein PUT mit unvollständigem Objekt leert also Felder, die du gar nicht anfassen wolltest. Das hat drei Folgen:
-
Vergleichen: Bevor du einen Datensatz schreibst, normalisierst du beide Seiten:
"", nur Leerzeichen undnullsind dasselbe, Dezimalwerte vergleichst du als Zahl, nicht als Text. So vermeidest du Updates, die nichts ändern. Das spart Last. -
Leeren: Ein Feld leerst du mit
nulloder einem leeren String. Das Ergebnis ist in beiden Fällennull. -
Entdecken: Mit
serializeNulls=trueenthält die Antwort auch Felder, dienullsind. Das ist hilfreich, wenn du wissen willst, welche Properties eine Entität hat. Die Antwortform ändert sich dadurch, schalte es nur gezielt ein.
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/article/id/<artikel-id>?serializeNulls=true
AuthenticationToken: <dein-token>
Zusatzfelder: typisierte Wertfelder
Zusatzfelder (customAttributes) hängen als Liste an der Entität. Jeder Eintrag hat die ID seiner Definition und genau ein Wertfeld, das zum Typ passt:
{
"customAttributes": [
{ "attributeDefinitionId": "7396436", "selectedValueId": "7396437" },
{ "attributeDefinitionId": "7396500", "dateValue": 1783634400000 },
{ "attributeDefinitionId": "7396501", "numberValue": "12.50" }
]
}
Weder Name noch Schlüssel stehen am Eintrag. Wer Werte nach Namen lesen will, ohne die Definition nachzuschlagen, greift ins Leere, und zwar ohne Fehlermeldung. Das Wertfeld hängt vom attributeType der Definition ab:
| attributeType | Wertfeld | Format |
|---|---|---|
BOOLEAN |
booleanValue |
true oder false
|
DATE |
dateValue |
Epoch-ms, Berlin-Mitternacht |
DECIMAL, INTEGER
|
numberValue |
Dezimal-String |
STRING, LARGE_TEXT, URL
|
stringValue |
Text |
LIST |
selectedValueId |
ID des Listenwerts |
MULTISELECT_LIST |
selectedValues |
Liste von { "id": … }
|
ENTITY |
entityId |
ID der Entität |
REFERENCE |
entityReferences |
Liste von { "entityId": …, "entityName": … }
|
Das Wertfeld leitest du deshalb immer aus dem attributeType der Definition ab, nie aus dem Namen des Felds.

Definitionen einmal laden und cachen
Die Definitionen ändern sich selten. Die Doku empfiehlt ausdrücklich, sie nur einmal abzurufen. Du lädst sie einmal pro Lauf mit schmaler Projektion:
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/customAttributeDefinition?properties=id,attributeKey,attributeType,readOnly,entities&pageSize=1000
AuthenticationToken: <dein-token>
Über attributeKey statt über feste IDs
Die Definitions-ID ist in jedem Mandanten eine andere. Wer sie fest einträgt, muss sie für jeden Kunden neu pflegen. Der attributeKey ist der stabile Name, den du selbst vergibst. Deshalb lösen Integrationen, die mandantenübergreifend laufen, die ID zur Laufzeit über den Schlüssel auf:
const VALUE_FIELD = {
BOOLEAN: 'booleanValue', DATE: 'dateValue', DECIMAL: 'numberValue', INTEGER: 'numberValue',
ENTITY: 'entityId', LIST: 'selectedValueId',
STRING: 'stringValue', LARGE_TEXT: 'stringValue', URL: 'stringValue',
// MULTISELECT_LIST und REFERENCE brauchen Listenstrukturen
};
function findDefinition(defs, entityName, attributeKey) {
const def = defs.find((d) => d.attributeKey === attributeKey && d.entities?.includes(entityName));
if (!def) throw new Error(`Zusatzfeld ${attributeKey} gibt es für ${entityName} nicht`);
return def;
}
// Lesen: IDs immer als String vergleichen
function readCustomAttribute(entity, def) {
const entry = (entity.customAttributes ?? [])
.find((a) => String(a.attributeDefinitionId) === String(def.id));
return entry?.[VALUE_FIELD[def.attributeType]] ?? null;
}
// Schreiben: nur der Eintrag, den du ändern willst
function customAttributePatch(def, value) {
const field = VALUE_FIELD[def.attributeType];
if (def.readOnly) throw new Error(`${def.attributeKey} ist schreibgeschützt`);
if (!field) throw new Error(`${def.attributeType} braucht eine eigene Struktur`);
return { customAttributes: [{ attributeDefinitionId: String(def.id), [field]: value }] };
}
Der Lookup prüft neben dem attributeKey auch den Entitätsnamen aus entities, etwa article oder party. So greifst du sicher zur Definition der Entität, die du gerade bearbeitest. Schreibgeschützte Definitionen (readOnly) kannst du nicht setzen, und dein Token braucht das Recht dafür. Bei DECIMAL prüfst du den erlaubten Wertebereich im Client, bevor du schreibst.
Beim Anlegen von Definitionen per API kommt für party das Feld legacyEntities dazu (customer, supplier, lead, competitor). Legst du Definitionen per API statt über die Oberfläche an, bekommt jeder Mandant dieselben Schlüssel.
Schreiben
Zusatzfelder werden wie normale Properties behandelt: Einträge, die du im Request weglässt, übernimmt weclapp aus der bestehenden Entität. Du schickst also nur, was sich ändert:
PUT https://<dein-mandant>.weclapp.com/webapp/api/v2/article/id/<artikel-id>?ignoreMissingProperties=true
AuthenticationToken: <dein-token>
Content-Type: application/json
{
"version": "<aktuelle-version>",
"customAttributes": [
{ "attributeDefinitionId": "<id-aus-der-definition>", "selectedValueId": "<id-des-listenwerts>" }
]
}
Danach liest du nach und prüfst den Wert.
Praktisch sind Zusatzfelder auch als Zustandsspeicher für Webhook-Flows: Eine Checkbox „Label erstellt“ sagt dem nächsten Lauf, dass der Schritt schon erledigt ist.
Filter auf Zusatzfelder
Der Filter braucht die ID der Definition im Parameternamen, und die kommt aus deinem Schlüssel-Lookup. Für Textfelder gilt customAttribute<ID>-eq=<Wert>, für Listen- und Mehrfachauswahlfelder customAttribute<ID>.id-eq=<Listenwert-ID> oder .value-eq=<Text>, für Referenzen customAttribute<ID>.entityReferences.entityId-eq=<ID>:
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/article?customAttribute<definitions-id>.id-eq=<listenwert-id>&properties=id,articleNumber
AuthenticationToken: <dein-token>
Mehrere Werte kombinierst du mit dem Ausdrucksfilter:
filter=(customAttribute<definitions-id>.id = '<listenwert-id-1>') or (customAttribute<definitions-id>.id = '<listenwert-id-2>')
Eine falsche ID im Parameternamen ist für weclapp eine unbekannte Property, und die ignoriert es laut Doku still. Du bekommst dann die ganze Collection. Prüfe deshalb mit /count, ob der Filter wirkt.
Checkliste
- Datumsfelder sind Epoch-Millisekunden; den Kalendertag bildest du über
Europe/Berlin, nie über UTC oder einen festen Offset. - Reine Datumswerte schreibst du als Berlin-Mitternacht, mit einem Helfer, der auch die Umstellungstage richtig trifft.
- Datumsfilter bekommen Epoch-Millisekunden und ein halboffenes Intervall (
-geund-lt), der Folgetag kommt aus dem Kalender statt aus+ 86400000. - Im n8n-Code-Node steht
timeZone: 'Europe/Berlin'an jeder Datumsformatierung. - Dezimalwerte sendest du als gerundeten String mit Punkt, summierst nie mit Gleitkomma und formatierst nie mit Komma.
- IDs bleiben Strings und werden als Strings verglichen.
- Vor dem Schreiben normalisierst du
"", Leerraum undnull, damit keine Updates ohne Änderung entstehen. - Zusatzfelder sprichst du über
attributeKeyan. Die Definitionen lädst du einmal pro Lauf und cachst sie. - Das Wertfeld folgt dem
attributeType, schreibgeschützte Definitionen werden übersprungen. - Jeder Datums- und Zusatzfeld-Filter ist mit einem Gegenzähler (
/count) geprüft.
Weiterlesen und Ausprobieren
Dieser Artikel gehört zur Serie „weclapp API: Das Handbuch“. Die Endpunkte aus diesem Artikel kannst du in unserer kostenlosen Postman Collection 4 weclapp gegen einen Testmandanten ausprobieren. Die Grundlagen zu Token, Filtern und Entitäten zeigen wir im Webinar „weclapp API Deep Dive“ auf YouTube. Und wenn du Datums- oder Zusatzfeldfehler in einer bestehenden Integration suchst, sprechen wir im Vorgespräch darüber: wals.pro/termin