Click here to book a no-obligation intro call!

Posted by Markus Wals

Dates, numbers, custom fields: the data type traps of the weclapp API

API customAttributes Datentypen Datum n8n weclapp Zeitzone Zusatzfelder

Dates, numbers, custom fields: the data type traps of the weclapp API

The most annoying errors in an API integration are the silent ones. No 400, no error message, just a delivery date that appears a day early in Slack, a price that weclapp rejects, or a custom field that never gets a value. In this article you'll find the data type traps of the weclapp API in one place, each with the rule that defuses it.

The basic rules are in the JSON section of the official weclapp API docs. We know the traps behind them from our integrations in continuous operation: Fulfillment 4 weclapp, wals.pro AI 4 weclapp and n8n flows for customers. Christian Lanig (weclapp) recommended at Community Day 2026 to transmit only changes when writing and to avoid unnecessary updates. Clean data types are the prerequisite for that.

Date fields are epoch milliseconds

The docs describe date and time values as milliseconds since 1970-01-01T00:00:00Z, as a JSON number. In the spec, all date fields are format: timestamp with type: integer. There is no date format without a time component. A sales order looks like this:

{
  "orderDate": 1783634400000,
  "plannedDeliveryDate": 1783720800000
}

orderDate is 10.07.2026, plannedDeliveryDate is 11.07.2026. The numbers don't reveal it, and that's exactly the trap. Pure date fields like orderDate, plannedDeliveryDate, dueDate or the dateValue of a custom field sit at the point in time of Berlin midnight:

Value read as UTC read in Berlin
1783634400000 09.07.2026, 22:00 10.07.2026, 00:00 (summer time, +02:00)
1798066800000 23.12.2026, 23:00 24.12.2026, 00:00 (winter time, +01:00)

The docs don't mention this Berlin midnight, but the data shows it unambiguously. A sales order from 7 October 2026 carries the orderDate 1791324000000, in UTC 6 October at 22:00. One from 27 February 2026 carries 1772146800000, in UTC 26 February at 23:00. Timestamps like createdDate and lastModifiedDate, on the other hand, are real points in time with hour, minute and millisecond. The docs' filter example already shows this, createdDate-gt=1398436281262, i.e. 25.04.2014 at 14:31:21 UTC.

The day offset: why 10 July becomes 9 July

If you render the value as UTC, you get the previous day. The code runs, nothing fails, and only a person comparing the result with weclapp notices. On a machine with UTC time zone, it looks like this:

const ms = 1783634400000; // 10.07.2026 in weclapp

new Date(ms).toISOString().split('T')[0];          // "2026-07-09"  wrong
new Date(ms).toLocaleDateString('de-DE');          // "9.7.2026"    wrong, uses the process time zone
new Date(ms).getDate();                            // 9             wrong

new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }); // "2026-07-10"  correct

Graphic: One value, two calendar days. 1783634400000 read as UTC is 9 July, 22:00, read in Europe/Berlin it is 10 July, 00:00. Always derive the calendar day via Europe/Berlin.

The rule is: you always derive the calendar day of a weclapp value via a real time zone, never via a fixed offset. Berlin is at +02:00 in summer and +01:00 in winter, a fixed deduction of two hours is wrong for half the year. In Python, ZoneInfo takes care of it:

from datetime import datetime
from zoneinfo import ZoneInfo

BERLIN = ZoneInfo("Europe/Berlin")
day = datetime.fromtimestamp(ms / 1000, BERLIN).date()   # 2026-07-10

Month boundaries are particularly tricky. 1 August 2026 in weclapp is 1785535200000, read in UTC it's 31 July, 22:00. If you bucket revenue or documents by month and use UTC for it, you assign the first day of every month to the previous month.

Writing a date: Berlin midnight instead of UTC midnight

When writing, the same applies in reverse. For 10.07.2026 you need the point in time of Berlin midnight, not Date.UTC(2026, 6, 10). The obvious approach is to take the offset from 12 noon of that day. That is off by one hour on the two changeover days of the year (2026: 29.03. and 25.10.), because there midnight and noon lie in different offsets. The following helper checks the two possible candidates directly:

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 midnight (epoch ms) for a calendar day "YYYY-MM-DD", also on changeover days
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]) {                 // winter time +1, summer time +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, int(datetime(2026, 7, 10, tzinfo=BERLIN).timestamp() * 1000) is enough. It is then written with the usual 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 }

With ignoreMissingProperties=true you change only the fields in the request, and according to the docs version activates optimistic locking and thus protects against parallel changes. Afterwards you read the record back and compare the calendar day in Berlin with your input.

Date filters: epoch milliseconds and half-open intervals

The docs' examples filter with epoch milliseconds, such as createdDate-gt=1398436281262. For a day you need two boundaries. Use ge for the start and lt for the start of the next day, not le with 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>

The next day is the Berlin midnight of the next calendar day, not an addition of 86,400,000 milliseconds. On 29.03.2026 the day in Berlin has 23 hours, on 25.10.2026 it has 25. The same pattern applies to months. July 2026 is orderDate-ge=1782856800000 and orderDate-lt=1785535200000. With /count instead of the list you check the quantity before you fetch anything. The parameter values appear unencoded in this article so they can be read; your HTTP client encodes them when sending.

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/salesOrder/count?orderDate-ge=1782856800000&orderDate-lt=1785535200000
AuthenticationToken: <dein-token>

In addition there is the filter expression filter= (beta). There you can write a date as a point in time in ISO-8601, the time zone being mandatory according to the docs, or an integer as epoch milliseconds:

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>

The offset belongs to the day: +02:00 in summer, +01:00 in winter. With Z instead of the offset you would lose the first day, because Berlin midnight lies two hours before 00:00 UTC.

The docs describe ISO literals only for the filter= expression, all examples for property filters work with epoch milliseconds. From this follows a simple rule: property filters get epoch milliseconds, filter expressions get a point in time with offset or a number. And because weclapp ignores unknown or non-filterable properties without an error message, you check every date boundary with a counter-count.

The n8n trap: settings.timezone doesn't apply in the Code node

n8n has the workflow setting settings.timezone, for example Europe/Berlin. It only controls n8n's own Luxon expressions like $now and $today as well as schedule triggers. A bare new Date() in the Code node, on the other hand, uses the time zone of the server process. If that runs in UTC, the workflow looks “set to Berlin” and still formats in UTC. Which time zone your instance has is revealed by Intl.DateTimeFormat().resolvedOptions().timeZone in a Code node.

The error has four faces, all with the same solution:

  • toLocaleDateString('de-DE') without timeZone
  • toISOString().split('T')[0]
  • getDate(), getUTCDate() and getMonth()
  • new Date(y, m, d) for month boundaries

The solution is always the same: explicitly pass timeZone: 'Europe/Berlin', independent of 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) },
}));

Decimal numbers are strings

Most numbers in weclapp, such as quantities and prices, are decimal numbers with fixed precision. According to the docs, weclapp serializes them as JSON strings so that no precision is lost when parsing with floating-point numbers, and always with a period as the decimal separator. The most common pattern in the spec (142 field entries, such as unitPrice) is ^-?([0-9]{1,13})([.][0-9]{1,5})?$: up to 13 digits before and 5 after the decimal point. That's up to 18 digits, more than a floating-point number can reliably carry.

{ "unitPrice": "19.90", "quantity": "2.5" }

Three rules follow from this.

Sending: round and send as a string with a period. The docs allow sending a number where a string is expected. But calculated values carry floating-point artifacts. A goods value like 155.68998629999996 for declaredValueAmount has 14 decimal places, the field's pattern allows five. A small helper puts the value into shape before sending:

const toDecimal = (value, digits = 2) =>
  (Math.round((value + Number.EPSILON) * 10 ** digits) / 10 ** digits).toFixed(digits);

toDecimal(155.68998629999996); // "155.69"

Never format with toLocaleString('de-DE'). That produces "1.234,5", and weclapp expects "1234.5".

Calculating: don't sum with floating point. 0.1 + 0.2 yields 0.30000000000000004 in JavaScript. For sums over many items, you calculate in whole cents or with a decimal library and round only at the end.

IDs stay strings. You read "id": "4711" as text and don't convert it to a number. The spec also types attributeDefinitionId as a string. If you compare IDs, always do so as strings, so that "4711" and 4711 don't miss each other.

Graphic: Decimal numbers are strings. Floating-point traps: 155.68998629999996 with 14 decimal places, 1.234,5 with a comma, 0.1 + 0.2. Correct: "155.69" rounded as a string, "1234.5" with a period, sum in cents.

Empty strings become null

An empty string, even one made of spaces, is always read by weclapp as null. There is no “empty but set” value. Without ignoreMissingProperties=true, moreover, every property missing from the request JSON counts as null. A PUT with an incomplete object therefore empties fields you didn't want to touch at all. This has three consequences:

  • Comparing: Before you write a record, you normalize both sides: "", only spaces and null are the same, and you compare decimal values as numbers, not as text. That way you avoid updates that change nothing. That saves load.
  • Clearing: You clear a field with null or an empty string. The result is null in both cases.
  • Discovering: With serializeNulls=true, the response also contains fields that are null. That's helpful if you want to know which properties an entity has. The response shape changes as a result, so switch it on only deliberately.
GET https://<dein-mandant>.weclapp.com/webapp/api/v2/article/id/<artikel-id>?serializeNulls=true
AuthenticationToken: <dein-token>

Custom fields: typed value fields

Custom fields (customAttributes) hang off the entity as a list. Each entry has the ID of its definition and exactly one value field that matches the type:

{
  "customAttributes": [
    { "attributeDefinitionId": "7396436", "selectedValueId": "7396437" },
    { "attributeDefinitionId": "7396500", "dateValue": 1783634400000 },
    { "attributeDefinitionId": "7396501", "numberValue": "12.50" }
  ]
}

Neither name nor key is on the entry. If you want to read values by name without looking up the definition, you grab thin air, without an error message. The value field depends on the definition's attributeType:

attributeType Value field Format
BOOLEAN booleanValue true or false
DATE dateValue Epoch ms, Berlin midnight
DECIMAL, INTEGER numberValue Decimal string
STRING, LARGE_TEXT, URL stringValue Text
LIST selectedValueId ID of the list value
MULTISELECT_LIST selectedValues List of { "id": … }
ENTITY entityId ID of the entity
REFERENCE entityReferences List of { "entityId": …, "entityName": … }

You therefore always derive the value field from the definition's attributeType, never from the field's name.

Graphic: The type determines the value field. DATE to dateValue (epoch ms, Berlin midnight), DECIMAL and INTEGER to numberValue (decimal string), LIST to selectedValueId, STRING and URL to stringValue.

Load definitions once and cache them

The definitions rarely change. The docs explicitly recommend fetching them only once. You load them once per run with a slim projection:

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/customAttributeDefinition?properties=id,attributeKey,attributeType,readOnly,entities&pageSize=1000
AuthenticationToken: <dein-token>

Via attributeKey instead of fixed IDs

The definition ID is different in every tenant. If you hard-code it, you have to maintain it anew for every customer. The attributeKey is the stable name that you assign yourself. That's why integrations that run across tenants resolve the ID at runtime via the key:

const VALUE_FIELD = {
  BOOLEAN: 'booleanValue', DATE: 'dateValue', DECIMAL: 'numberValue', INTEGER: 'numberValue',
  ENTITY: 'entityId', LIST: 'selectedValueId',
  STRING: 'stringValue', LARGE_TEXT: 'stringValue', URL: 'stringValue',
  // MULTISELECT_LIST and REFERENCE need list structures
};

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;
}

// Reading: always compare IDs as strings
function readCustomAttribute(entity, def) {
  const entry = (entity.customAttributes ?? [])
    .find((a) => String(a.attributeDefinitionId) === String(def.id));
  return entry?.[VALUE_FIELD[def.attributeType]] ?? null;
}

// Writing: only the entry you want to change
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 }] };
}

Besides the attributeKey, the lookup also checks the entity name from entities, such as article or party. That way you safely reach the definition of the entity you're currently editing. You can't set read-only definitions (readOnly), and your token needs the permission for it. For DECIMAL, you check the permitted value range in the client before writing.

When creating definitions via API, the field legacyEntities is added for party (customer, supplier, lead, competitor). If you create definitions via API instead of the interface, every tenant gets the same keys.

Writing

Custom fields are treated like normal properties: entries you omit in the request are taken over by weclapp from the existing entity. So you send only what changes:

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>" }
  ]
}

Afterwards you read back and check the value.

Custom fields are also practical as state storage for webhook flows: a checkbox “label created” tells the next run that the step is already done.

Filters on custom fields

The filter needs the ID of the definition in the parameter name, and that comes from your key lookup. For text fields customAttribute<ID>-eq=<value> applies, for list and multi-select fields customAttribute<ID>.id-eq=<list value ID> or .value-eq=<text>, for references 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>

You combine several values with the expression filter:

filter=(customAttribute<definitions-id>.id = '<listenwert-id-1>') or (customAttribute<definitions-id>.id = '<listenwert-id-2>')

A wrong ID in the parameter name is an unknown property for weclapp, and according to the docs it silently ignores it. You then get the whole collection. So check with /count whether the filter takes effect.

Checklist

  • Date fields are epoch milliseconds; you derive the calendar day via Europe/Berlin, never via UTC or a fixed offset.
  • You write pure date values as Berlin midnight, with a helper that also hits the changeover days correctly.
  • Date filters get epoch milliseconds and a half-open interval (-ge and -lt), the next day comes from the calendar instead of + 86400000.
  • In the n8n Code node, timeZone: 'Europe/Berlin' is on every date formatting.
  • You send decimal values as a rounded string with a period, never sum with floating point and never format with a comma.
  • IDs stay strings and are compared as strings.
  • Before writing, you normalize "", whitespace and null, so that no updates without a change occur.
  • You address custom fields via attributeKey. You load the definitions once per run and cache them.
  • The value field follows the attributeType, read-only definitions are skipped.
  • Every date and custom field filter is checked with a counter-count (/count).

Keep reading and try it out

This article is part of the series “weclapp API: The Handbook”. You can try the endpoints from this article against a test tenant in our free Postman Collection 4 weclapp. We cover the basics on tokens, filters and entities in the webinar “weclapp API Deep Dive” on YouTube. And if you're looking for date or custom field errors in an existing integration, we'll talk about it in an intro call: wals.pro/termin