API principles
This page documents the behaviors that hold across every v1 resource: idempotency, traceability, update semantics, pagination, date-time formats, and error handling.
Idempotency
Create-or-update by code
POST /{resource}/{code} creates the resource if it does not exist, or
updates it in place if it does. Calling it twice with the same payload
produces the same end state. This pattern is used across sellers, charges,
products, and trading entities.
External reference on orders
Orders support client-supplied idempotency via externalReference. Reusing
an externalReference on a subsequent call is how a client safely retries an
order submission without creating a duplicate. When debugging "an action
doesn't seem to happen" on an idempotent endpoint, check whether an earlier,
failed attempt already left a row keyed by the same reference. A stale
reference from an earlier failed run can make a later call look like a no-op.
Traceability
Every v1 entity response includes an auditable block with who created and
last updated the record, and when:
{
"auditable": {
"created": "2026-07-01T09:12:45+02:00",
"creator": "api-client",
"updated": "2026-07-03T14:02:10+02:00",
"updater": "jane.doe"
}
}
auditable is read-only. It is never accepted in a request body; sending it
on create or update has no effect.
Source: org.billerang.api.common.AuditableDto
(billerang-backend/billerang-api/src/main/java/org/billerang/api/common/AuditableDto.java),
fields created, creator, updated, updater.
Update semantics: null vs empty
PUT (and the update side of POST /{code} upsert) uses one rule for every
scalar and every array field:
| Field type | null or absent | Empty value for the type |
|---|---|---|
| Scalar (string, code reference, number) | keep existing (not part of this update) | "" clears the field |
| Array / collection | keep existing (not part of this update) | [] removes all entries |
null always means "not part of this update." The empty value for the type
always means "clear." Any other value means "apply this value." This is
intentionally different from JSON Merge Patch (RFC 7396), which overloads
null to mean "clear" and therefore cannot express "leave this alone" for a
partial payload built from incomplete form state.
Why this rule instead of alternatives:
- Consistency. Scalars and arrays follow the same mental model. No case-by-case exceptions to memorize.
- Unambiguous. Clients that build payloads from partial form state can
confidently send
nullfor fields they do not manage. - Idempotent. Resending the same payload yields the same result. There is no toggling or state-dependent behavior.
Worked examples. All against PUT /api/v1/products/PROD_SMART_PHONE; the
rules apply equally to any other v1 resource.
- Clear a scalar — body
{"brand": ""}clearsbrandand leaves every other field (includingproductLine) untouched, because they were not mentioned in the request. - Keep a scalar untouched —
{"label": "New label"}changes only the label;brandstays as it was, whether it is omitted or sent asnull, since both mean the same thing. - Replace an array — sending
currentVersion.productAttributes: [{"attributeCode": "COLOR"}, {"attributeCode": "STORAGE_GB"}]fully replaces the version's attributes; any attribute not in the list is removed. - Clear an array —
currentVersion.productAttributes: []removes all attributes from the version. The product itself (label, brand, charges) is untouched.
How clients should build payloads
When a user clears an input in a form, the payload must send the
empty value for the field type, not null:
| UI action | Scalar field | Array field |
|---|---|---|
| User leaves the field alone | omit, or send null | omit, or send null |
| User clears the field | send "" | send [] |
| User enters/selects a value | send the value | send the non-empty array |
Controlled inputs typically produce "" when cleared, which is what the API
wants. But custom picker widgets, reducers that normalize empty state to
null, or ?? null-coalescing often convert "" back to null on the
wire; the backend then treats the update as "don't touch" and the user sees
their change silently ignored. If you see "I cleared the field, saved, but
the old value came back", audit the payload builder for that form.
Pagination and sorting
List endpoints accept:
| Parameter | Type | Description |
|---|---|---|
limit | number | Maximum number of results. Default 100. Capped at 1000 even if a higher value is requested. |
offset | number | Number of results to skip. Default 0. |
sortBy | string | Field name to sort by. |
sortOrder | string | ASCENDING or DESCENDING. |
The default and cap come from two server-side settings,
api.list.defaultLimit (100) and api.list.maxLimit (1000). A request for a
larger limit is not rejected; the server silently applies the cap and the
response's own limit field reflects the value that was actually applied.
Source: docs/adr/ADR-v1-query-api.md, section "Real classes this design
reuses," citing GenericPagingAndFilteringUtils.getLimit(userLimit) and its
test fixture
billerang-backend/billerang-api/src/test/resources/query/billerang-admin.properties
(api.list.defaultLimit=100, api.list.maxLimit=1000).
Date-time format
All timestamps in v1 responses use ISO 8601 with a numeric UTC offset and no
fractional seconds: pattern yyyy-MM-dd'T'HH:mm:ssXXX, for example
2026-07-03T14:02:10+02:00.
Source: this exact pattern string is defined as ISO_FORMAT in
org.billerang.api.common.AuditableDto and repeated in
OfferTemplateV1ResourceImpl, ChargeV1ResourceImpl, ChargeV1Mapper,
ProductV1ResourceImpl, DiscountPlanV1ResourceImpl, and
ArticleV1ResourceImpl (all under billerang-backend/billerang-api/src/main/java/org/billerang/api/).
Plain calendar dates (no time component), such as a pricing version's
validFrom/validTo, use yyyy-MM-dd. Source: SimpleDateFormat("yyyy-MM-dd")
usages in ChargeV1ResourceImpl.java and ChargeV1Mapper.java.
CDR ingestion has a third pattern, but only on the CSV wire format:
yyyy-MM-dd'T'HH:mm:ssxx (lowercase xx, a 4-digit offset with no colon,
e.g. +0200 rather than +02:00), from CdrV1Mapper.CSV_DATE_FMT. Verified
live: the JSON CDR dialect (POST /api/v1/cdrs/registration and
/rating with Content-Type: application/json) accepts standard ISO 8601
(colon in the offset, or Z) same as every other v1 endpoint; only the
text/plain/text/csv dialect uses the colon-less offset, because that is
the format org.meveo.admin.parse.csv.MEVEOCdrParser (the underlying legacy
CSV reader) expects. See the usage mediation
quickstart for both dialects run live.
Error model
Error responses are JSON. The exact shape currently varies slightly by
resource; all variants use "status": "FAIL".
Most v1 resources (verified in SellerV1ResourceImpl, ProductV1ResourceImpl,
and others) return:
{
"status": "FAIL",
"message": "Seller not found: SELLER_EU_001"
}
Some resources add a machine-readable error_code (verified in
ProductV1ResourceImpl.java):
{
"status": "FAIL",
"error_code": "PRICING_OVERLAP",
"message": "A published pricing version already covers this date range"
}
Schema/metadata endpoints (verified in WorkbenchSchemaRsImpl.errorBody,
which backs the Query API's entity-metadata routes) use a code field
instead of status/error_code:
{
"code": "ENTITY_NOT_FOUND",
"message": "Unknown entity 'Invoicee'"
}
The three shapes above coexist in the codebase today ({status, message},
{status, error_code, message}, {code, message}). Confirm with the API
Architect whether one shape is meant to become canonical for all of v1, or
whether the code-only shape is intentionally scoped to schema/metadata
endpoints only.
Standard HTTP status codes
| Status | Meaning |
|---|---|
400 | Bad request: missing or invalid parameter, malformed JSON |
404 | Entity not found |
409 | Conflict, for example a duplicate code or an overlapping publish window |
422 | Business rule violation (request is well-formed but not allowed) |
Source: docs/adr/ADR-v1-query-api.md, section (f) "Error behavior," and the
400/404 mappers referenced there (BadRequestExceptionMapper,
NotFoundExceptionMapper).