Skip to main content

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 typenull or absentEmpty value for the type
Scalar (string, code reference, number)keep existing (not part of this update)"" clears the field
Array / collectionkeep 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 null for 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.

  1. Clear a scalar — body {"brand": ""} clears brand and leaves every other field (including productLine) untouched, because they were not mentioned in the request.
  2. Keep a scalar untouched{"label": "New label"} changes only the label; brand stays as it was, whether it is omitted or sent as null, since both mean the same thing.
  3. 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.
  4. Clear an arraycurrentVersion.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 actionScalar fieldArray field
User leaves the field aloneomit, or send nullomit, or send null
User clears the fieldsend ""send []
User enters/selects a valuesend the valuesend the non-empty array
Symptom to watch for

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:

ParameterTypeDescription
limitnumberMaximum number of results. Default 100. Capped at 1000 even if a higher value is requested.
offsetnumberNumber of results to skip. Default 0.
sortBystringField name to sort by.
sortOrderstringASCENDING 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'"
}
TODO-VERIFY

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

StatusMeaning
400Bad request: missing or invalid parameter, malformed JSON
404Entity not found
409Conflict, for example a duplicate code or an overlapping publish window
422Business 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).