Skip to content

API Design

This document consolidates conventions for Arda’s REST API design: URL naming, required headers, error responses, filtering and pagination, endpoint usage patterns, known limitations, and hard-won gotchas.

Arda’s platform uses four network domains:

DomainPurpose
app.arda.cardsEnd-user-oriented applications
io.arda.cards / api.arda.cardsAPIs exposing platform functionality
auth.arda.cardsOAuth2 authorization endpoints

Each Purpose defines subdomains:

<purpose>.<infrastructure>.app.arda.cards
<purpose>.<infrastructure>.io.arda.cards
<purpose>.<infrastructure>.api.arda.cards
<purpose>.<infrastructure>.auth.arda.cards

The main production system uses canonical short hostnames:

live.app.arda.cards
live.io.arda.cards
live.api.arda.cards
live.auth.arda.cards

API endpoint routes follow:

https://<purpose>.<infrastructure>.api.arda.cards/<major-version>/<endpoint-name>/<specific-route>

Where:

  • <major-version> is the SemVer major version of the API definition (not the module or component version)
  • <endpoint-name> is the functional name of the endpoint, following REST naming conventions, in hyphenated lower-kebab-case plural form (e.g., lookup-suppliers, kanban-card)
  • <specific-route> is the route as understood in OpenAPI specification

Existing routes use hyphenated, plural naming: lookup-suppliers, lookup-units. New routes must follow the same pattern.

Every API call requires:

Authorization: Bearer <token>
X-Author: <author-uuid>
X-Tenant-Id: <tenant-uuid>
X-Request-ID: <fresh-uuid-per-request> # Must be valid UUID format
Content-Type: application/json

Critical notes:

  • Missing X-Request-ID causes a misleading 400 error about “Invalid UUID format”
  • Missing X-Tenant-Id also causes 400
  • Generate a fresh UUID for each request (e.g., $(uuidgen) in bash)

All error responses use standard HTTP status codes:

Client Errors (4xx):

  • 400 Bad Request — Invalid request payload, malformed JSON, missing required parameters
  • 401 Unauthorized — Missing or invalid authentication token
  • 403 Forbidden — Authenticated user lacks permission
  • 404 Not Found — Requested resource does not exist
  • 405 Method Not Allowed
  • 409 Conflict — Conflict in current resource state (e.g., edit conflict, update without draft)
  • 422 Unprocessable Entity
  • 429 Too Many Requests

Server Errors (5xx):

  • 500 Internal Server Error — Unexpected server error
  • 501 Not Implemented
  • 502 Bad Gateway
  • 503 Service UnavailableTransient failure; retry recommended. Accompanied by a Retry-After header (in seconds). See Transient failures and retry contract below.
  • 504 Gateway Timeout

PDEV-490 introduced an AppError.Transient branch for failures that callers may retry. Currently it is used to surface AWS Advanced JDBC Wrapper failover-window exceptions (the operations component runs on Aurora; the wrapper raises typed exceptions when an Aurora failover interrupts an in-flight transaction).

Response contract:

  • HTTP status: 503 Service Unavailable.
  • HTTP header: Retry-After: 2 (seconds).
  • Response body: standard ErrorResponse shape (responseMessage, code: 503, optional details carrying the underlying cause).

Caller behaviour expected:

  • Wait for the duration in Retry-After.
  • Re-issue the same request. Idempotent endpoints will succeed once the underlying failover completes (typically 2–5 seconds for an Aurora failover).
  • Non-idempotent endpoints should retry only when the caller can tolerate the operation being applied twice; otherwise surface the 503 to the user.

Server-side absorption: the inTransactionAsync / inTransactionSync boundary in common-module already retries transient failures up to PoolConfig.maxAttempts (default 2) with PoolConfig.backoffMs (default 300 ms) between attempts. The 503 is surfaced only when in-process retries are exhausted; most failovers are absorbed transparently and the request completes with HTTP 200.

Sub-types (visible only in logs / details):

AppError subtypeWrapper exceptionMeaning
AppError.Transient.FailoverSucceededFailoverSuccessSQLExceptionAurora failover completed mid-transaction; the in-flight transaction must be retried.
AppError.Transient.TransactionStateUnknownTransactionStateUnknownSQLExceptionA connection event left the transaction in an unknown state; caller should retry.
AppError.Transient.FailoverFailedFailoverFailedSQLExceptionAurora failover did not complete within the wrapper’s retry window; caller may retry after a longer back-off.

All error responses conform to the ErrorResponse data class:

@Serializable
data class ErrorResponse(
override val responseMessage: String,
override val code: Int, // HTTP Status Code
override val details: JsonElement? = null
) : Throwable(...), HttpResponse

The details field can contain:

  1. Simple Cause: JSON representation of a cause ErrorResponse
  2. Contextual Information (SingleDetails):
    data class SingleDetails(
    val error: ErrorResponse,
    override val context: String?
    ): ErrorDetails
  3. Composite Errors (CompositeDetails):
    data class CompositeDetails(
    override val context: String?,
    val errors: List<ErrorResponse>
    ): ErrorDetails

Example composite error JSON:

{
"responseMessage": "Multiple errors occurred",
"code": 500,
"details": {
"context": "Validating user input",
"errors": [
{ "responseMessage": "username is Invalid: cannot be empty", "code": 400, "details": null },
{ "responseMessage": "email is Invalid: must be a valid email format", "code": 400, "details": null }
]
}
}

A route handler never constructs an error response. It returns a Result and lets the framework throw, or it throws an AppError directly. A single StatusPages handler, installed once per component in common-module’s Component.kt, turns every failure into the response.

This is the rule that most often gets reinvented, so it is worth being explicit about what a hand-rolled responder costs. The central handler does five things, each of which is silently lost by a handler that calls call.respond on its own failure path:

What the boundary doesTicket
1Captures to Sentry via captureFromKtorBoundary(throwable, route, callId). Internal.* and Generic become events tagged boundary=http; Invocation.* are dropped by the reportable() policy.
2Attaches the correlation id to the response body, so a caller can pivot from a user-facing error to the matching log line. The CallId plugin already sets the header; this puts it in the payload.PDEV-378
3Sets Retry-After on AppError.Transient — and it must happen before call.respond(...), because Ktor commits headers once the body starts being written.PDEV-490
4Logs by severity. 4xx logs message and context only — a legitimate user error is not a stack trace. 5xx logs the full stack. MDC carries callId, method, and route into every appender.PDEV-378
5Normalizes and classifies non-AppError exceptions. BadRequestException, JsonConvertException, SerializationException, and IllegalArgumentException become AppError.GeneralValidation, which reportable() then drops — keeping bad client input out of Sentry.

An abridged view of the handler (see common-module/lib/.../component/Component.kt for the current source):

install(StatusPages) {
exception<Throwable> { call, ktorExc ->
val cause = ktorExc.cause
val toProcess = if (cause != null && ktorExc.message == cause.message) cause else ktorExc
val appError = toProcess.normalizeToAppError()
// PDEV-490: before respond() — Ktor commits headers when the body starts.
if (appError is AppError.Transient) call.response.header(HttpHeaders.RetryAfter, "2")
val callId = call.attributes.getOrNull(CallFieldNames.callIdAttributeKey)
val response = appError.toErrorResponse(normalizing = false)
.let { base -> if (callId == null) base else base.withCorrelationId(callId) } // PDEV-378
captureFromKtorBoundary(throwable = appError, route = call.request.path(), callId = callId)
// …severity-aware logging: info+context for 4xx, full stack for 5xx…
call.respond(status = response.httpCode, message = response)
}
}

In a DSL-declared endpoint — which is every module endpoint — you write nothing. Invocation.responder folds the handler’s Result and rethrows the failure:

execute(rCtx.call).fold(
{ r -> … respond … },
{ err -> throw err }, // deliberate: this is how the boundary is reached
)

So the handler simply returns Result<T> and the framework does the rest. See Endpoint Definition DSL.

In a hand-written Ktor handler, throw the AppError yourself:

get("/my-resource/{id}") {
val id = call.parameters["id"]
?: throw AppError.ArgumentValidation("id", "ID path parameter is missing")
val resource = fetchResource(id)
?: throw AppError.NotFound("MyResource", context = { "Resource ID: $id" })
call.respond(resource)
}

On getOrThrow(). The Kotlin coding standards forbid getOrThrow and getOrNull for extracting a value from a Result. The HTTP boundary is the single sanctioned exception: it is the one place where a Result legitimately becomes a throw. Even there you rarely write it — Invocation.responder already does — so getOrThrow() is confined to hand-written Ktor handlers, and a hand-written Ktor handler inside a module is itself a signal that the endpoint should be on the DSL.

Prefer strong types (UUID/EntityId) over String at API boundaries to reduce parsing boilerplate.

The Money type uses value (not amount) for the numeric field:

// CORRECT
"unitCost": { "value": 0.15, "currency": "USD" }
// WRONG — returns 400
"unitCost": { "amount": 0.15, "currency": "USD" }

The Quantity type uses amount and unit:

"quantityPerOrder": { "amount": 100, "unit": "EA" }

The API is strict about types for Quantity objects. Float values fail:

{"amount": 1.0, "unit": "each"} // Returns 400
{"amount": 1, "unit": "each"} // Correct

Watch for camelCase variations — defaultSupplyEid (lowercase ‘id’) vs supplyEId.

For kotlinx.serializable data classes with UUID fields, the @Contextual annotation is required.

The /query sub-path supports filtering, sorting, and pagination:

  • Root path: /<version>/<resource> (e.g., /v1/items)
  • Query sub-path: /<version>/<resource>/query (e.g., /v1/items/query)

POST /<version>/<resource>/query with a JSON Query object body:

{
"filter": { "EQ": { "locator": "status", "value": "ACTIVE" } },
"sort": [
{ "field": "name", "direction": "ASC" }
],
"paginate": { "index": 0, "size": 20 }
}

Response is a PageResult:

@Serializable
data class PageResult<P: EntityPayload, M : PayloadMetadata>(
val thisPage: String,
val nextPage: String,
val previousPage: String?,
val results: List<EntityRecord<P, M>>
)

GET /<version>/<resource>/query/{page} — retrieve a specific page using a token from nextPage or previousPage. Returns the same PageResult structure.

If {page} cannot be decoded to a proper Query, returns an Argument Validation error.

  • The /query endpoints return only non-retired, visible records by default
  • An empty result does not mean no data exists — verify with direct GET /entity-id calls
  • The filter parameter is optional; omitting it returns all records
  • Query parameter defaults: effectiveAsOf and recordedAsOf default to TimeCoordinates.now() when absent

See Query DSL for filter syntax.

Universes configured with EntityServiceConfiguration accept locators as JSON field paths (camelCase, e.g., identity.email, cardQuantity.amount) in addition to raw database column names (snake_case, e.g., identity_email, card_quantity_amount). The structured translator resolves both forms, so API clients can use whichever style matches their context. See Query DSL: EntityServiceConfiguration.

Updating an item requires a strict Draft → Publish workflow due to the bitemporal model:

  1. Get/Create Draft: GET /v1/item/item/{eId}/draft
  2. Publish: PUT /v1/item/item/{eId} with the Item payload as the body

Calling PUT /item/{eId} without a draft existing returns 400 Cannot update... without a draft.

Delete is a logical retirement, not physical removal — data is preserved to maintain bitemporal history. The response includes "retired": true.

Items with kanban card “successors” in the bitemporal timeline cannot be deleted. The API returns: "Record[ITEM] cannot be deleted because it has a successor". Retire items instead, or delete all associated cards first.

The /lookup-* endpoints (suppliers, facilities, units, types, subtypes, usecases, departments) are typeahead/autocomplete endpoints. They require a name query parameter and return matches, not full lists. Use the /query endpoints for full data listing.

All API responses wrap data in a payload property. For queries:

  • payload.data is the results array
  • payload.total is the count

The locator field on items is a nested object {facility, department, location, subLocation}, not a flat string.

  1. Query Object Complexity: Clients are responsible for constructing the JSON Query object; errors produce runtime failures, not schema validation errors.
  2. Page Token Security: Current page tokens are strings (serialized Query); no integrity checking is implemented.
  3. Performance: Query performance depends on Universe implementation and database indexing. No projection support — full EntityRecord is always returned.
  4. No Full-Text Search: The filter mechanism is for structured data only.
  5. No Aggregations: No standard mechanism for COUNT, SUM, AVG via query API.
  6. GET Pagination: Filters only apply on the initial POST request; GET /query/{page} does not accept a filter body.

Universes with EntityServiceConfiguration accept both camelCase JSON field paths and snake_case column names as locators. For universes not yet configured with a structured translator, only raw column names are accepted — test before relying on camelCase paths.

Orphaned card references (cards that reference deleted items) can cause 500 errors on the /details endpoint.

The frontend does not automatically poll for data changes. After API mutations, manual browser refresh is required to see changes in the UI.