API Request & Response Format#

The complete HTTP contract for every Palmyra handler. Use this page if you’re building your own frontend (mobile, desktop, CLI, third-party integration) and need to know exactly what to send and what comes back — without reading the handler reference pages.

Every Palmyra endpoint speaks JSON. The request body (when present) is Content-Type: application/json. The response is always application/json.

Throughout this page, the examples assume the following model and handler:

@PalmyraType(type = "Employee", table = "employee", preferredKey = "loginName")
public class EmployeeModel {
    @PalmyraField(primaryKey = true)                         private Long      id;
    @PalmyraField(sort = true, search = true)                private String    loginName;
    @PalmyraField(sort = true, search = true)                private String    firstName;
    @PalmyraField(sort = true, search = true)                private String    lastName;
    @PalmyraField(attribute = "department")
    @FetchConfig(fetchMode = FetchMode.PRIMITIVE_OR_KEY_FIELDS)
    private DepartmentModel department;
    @PalmyraField(parentRef = "department", attribute = "code")
    private String departmentCode;
    @PalmyraField(sort = true)                               private LocalDate joiningDate;
    @PalmyraField(sort = true)                               private String    status;
    @PalmyraField(drop = DropMode.INCOMING)                  private Instant   createdAt;
    @PalmyraField(drop = DropMode.INCOMING)                  private String    createdBy;
}

@Component
@CrudMapping(
    mapping          = "/employee",
    type             = EmployeeModel.class,
    secondaryMapping = "/employee/{id}"
)
public class EmployeeHandler
    implements QueryHandler, ReadHandler, CreateHandler, UpdateHandler, SaveHandler, DeleteHandler { }

Base URL for all examples: http://localhost:8080/api


1. List / Query — GET <mapping>#

Handler: QueryHandler

Request#

GET /api/employee?status=ACTIVE&department.code=ENG&_limit=15&_offset=0&_orderBy=-joiningDate,firstName&_total=true

Query parameters

Parameter Type Required Purpose
Any @PalmyraField attribute string No Equality filter — e.g. status=ACTIVE, firstName=Ada
Dotted attribute path string No Filter on a joined parent column — e.g. department.code=ENG (Palmyra adds the JOIN automatically)
_limit number No (default 15) Maximum rows to return
_offset number No (default 0) Rows to skip — for paging
_orderBy comma-separated No Sort columns — prefix - for descending; e.g. -joiningDate,firstName
_total true / false No (default false) When true, runs a count query and includes total in the response
_fields comma-separated No Restrict the response to these attributes only — e.g. _fields=id,firstName,lastName

Request body: none.

Response — 200 OK#

{
  "result": [
    {
      "id": 102,
      "loginName": "ada@example.com",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "department": { "id": 2, "code": "ENG", "name": "Engineering" },
      "departmentCode": "ENG",
      "joiningDate": "2025-11-03",
      "status": "ACTIVE",
      "createdAt": "2026-04-01T09:12:00Z",
      "createdBy": "admin@example.com"
    },
    {
      "id": 101,
      "loginName": "grace@example.com",
      "firstName": "Grace",
      "lastName": "Hopper",
      "department": { "id": 2, "code": "ENG", "name": "Engineering" },
      "departmentCode": "ENG",
      "joiningDate": "2025-08-15",
      "status": "ACTIVE",
      "createdAt": "2026-03-28T14:05:00Z",
      "createdBy": "admin@example.com"
    }
  ],
  "limit": 15,
  "offset": 0,
  "total": 127
}
Field Always present Notes
result Yes Array of objects — empty [] when no rows match
limit Yes Echoed from the request (or the default 15)
offset Yes Echoed from the request (or 0)
total Only when _total=true Total row count across all pages — omitted to save the count query when not requested

Notes for mobile / third-party clients:

  • The department sub-object is included because the model declares it as a nested FK. Its depth is controlled by @FetchConfig — in this example, PRIMITIVE_OR_KEY_FIELDS returns id + scalar columns but no further nested objects.
  • departmentCode is a flattened shortcut via parentRef — it carries the same value as department.code but at the top level for simpler grid binding.
  • Fields tagged DropMode.INCOMING (createdAt, createdBy) are still returned in responses — only incoming writes are ignored.

2. Read single record — GET <secondaryMapping>#

Handler: ReadHandler

Request#

GET /api/employee/102

{id} in the URL is the primary key of the table. No query parameters, no request body.

Response — 200 OK#

A single JSON object — not wrapped in an envelope:

{
  "id": 102,
  "loginName": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "department": { "id": 2, "code": "ENG", "name": "Engineering" },
  "departmentCode": "ENG",
  "joiningDate": "2025-11-03",
  "status": "ACTIVE",
  "createdAt": "2026-04-01T09:12:00Z",
  "createdBy": "admin@example.com"
}

Response — 404 Not Found#

When no row matches the given id. Body may be empty or a framework error object.


3. Create — POST <mapping>#

Handler: CreateHandler

Request#

POST /api/employee
Content-Type: application/json
{
  "loginName": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "department": { "id": 2 },
  "joiningDate": "2025-11-03",
  "status": "ACTIVE"
}

Rules:

  • Fields marked mandatory = Mandatory.CREATE or mandatory = Mandatory.ALL must be present — Palmyra returns a validation error otherwise.
  • Fields marked DropMode.INCOMING (createdAt, createdBy) are silently ignored if the client sends them — the server is the authoritative writer.
  • Foreign keys are set by passing the referenced object with its primary key only — e.g. "department": { "id": 2 }. You don’t need to pass the full parent object.
  • The id field (primary key) should be omitted — the database generates it.

Response — 200 OK#

The newly-inserted row with all server-populated fields filled in:

{
  "id": 102,
  "loginName": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "department": { "id": 2, "code": "ENG", "name": "Engineering" },
  "departmentCode": "ENG",
  "joiningDate": "2025-11-03",
  "status": "ACTIVE",
  "createdAt": "2026-04-01T09:12:00Z",
  "createdBy": "admin@example.com"
}

Response — 400 Bad Request#

When a mandatory field is missing or a validation pattern fails.


4. Update — POST <secondaryMapping>#

Handler: UpdateHandler

Request#

POST /api/employee/102
Content-Type: application/json
{
  "firstName": "Ada",
  "lastName": "Lovelace-Byron",
  "status": "ACTIVE"
}

Rules:

  • The {id} in the URL identifies the row to update.
  • The body carries only the fields to change — partial payloads are fine. Omitted attributes are left untouched in the database.
  • Fields marked mandatory = Mandatory.UPDATE or mandatory = Mandatory.ALL must be present if supplied in the model.
  • To change a foreign key, send the new reference: "department": { "id": 5 }.

Response — 200 OK#

The post-update row (full object, same shape as a Read response):

{
  "id": 102,
  "loginName": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace-Byron",
  "department": { "id": 2, "code": "ENG", "name": "Engineering" },
  "departmentCode": "ENG",
  "joiningDate": "2025-11-03",
  "status": "ACTIVE",
  "createdAt": "2026-04-01T09:12:00Z",
  "createdBy": "admin@example.com"
}

Response — 404 Not Found#

When no row matches the given id.


5. Upsert (Save) — POST <mapping>#

Handler: SaveHandler

Request#

POST /api/employee
Content-Type: application/json

Same URL and method as Create — the framework decides insert or update by looking up the row using the primary key, or the preferredKey / unique keys declared on the model.

Insert path — no matching row found:

{
  "loginName": "grace@example.com",
  "firstName": "Grace",
  "lastName": "Hopper",
  "department": { "id": 2 },
  "joiningDate": "2025-08-15"
}

Update pathloginName matches an existing row (because preferredKey = "loginName"):

{
  "loginName": "ada@example.com",
  "lastName": "Lovelace-Byron"
}

How the lookup works:

  1. Palmyra checks the primary key (id) in the payload — if present and a row matches, it’s an update.
  2. If no id (or id doesn’t match), it checks the preferredKey — in this example, loginName.
  3. If preferredKey isn’t set, it checks all declared unique keys (OR’d together).
  4. If nothing matches, it’s an insert.

Response — 200 OK#

Same shape as Create / Update — the full post-write row:

{
  "id": 102,
  "loginName": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace-Byron",
  "department": { "id": 2, "code": "ENG", "name": "Engineering" },
  "departmentCode": "ENG",
  "joiningDate": "2025-11-03",
  "status": "ACTIVE",
  "createdAt": "2026-04-01T09:12:00Z",
  "createdBy": "admin@example.com"
}

There is no difference in the response shape between an insert and an update — the caller can inspect the id or track whether they supplied one to determine which path ran.


6. Delete — DELETE <secondaryMapping>#

Handler: DeleteHandler

Request#

DELETE /api/employee/102

No request body. {id} is the primary key of the row to delete.

Response — 200 OK#

The deleted row (returned so the caller can confirm what was removed):

{
  "id": 102,
  "loginName": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace-Byron",
  "department": { "id": 2, "code": "ENG", "name": "Engineering" },
  "departmentCode": "ENG",
  "joiningDate": "2025-11-03",
  "status": "ACTIVE",
  "createdAt": "2026-04-01T09:12:00Z",
  "createdBy": "admin@example.com"
}

Response — 404 Not Found#

When no row matches the given id.


7. CSV export — GET <mapping> with _format=csv#

Handler: CsvHandler (must be published at its own @CrudMapping path)

Request#

GET /api/employee/export.csv?status=ACTIVE&_orderBy=-joiningDate

Same query parameters as the list endpoint. The _format parameter or a dedicated URL suffix (.csv) triggers the export.

Response — 200 OK#

Content-Type: text/csv
Content-Disposition: attachment; filename="export.csv"

Email,First Name,Last Name,Joined
ada@example.com,Ada,Lovelace,2025-11-03
grace@example.com,Grace,Hopper,2025-08-15

Column headers and values come from ColumnMeta.getHeaders() on the handler — the export streams reactively, so it scales to millions of rows.


8. Excel export — GET <mapping> with _format=excel#

Handler: ExcelHandler (must be published at its own @CrudMapping path)

Request#

GET /api/employee/export.xlsx?department.code=ENG&_orderBy=firstName

Response — 200 OK#

Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="Report.xlsx"

(binary Excel workbook)

The workbook name comes from ExcelHandler.getReportName() (default "Report").


Quick reference — URL ↔ handler mapping#

HTTP method URL Handler Request body Response
GET /employee QueryHandler { result: [...], limit, offset, total? }
GET /employee/{id} ReadHandler Single object
POST /employee CreateHandler New object (no id) Inserted object (with id)
POST /employee SaveHandler Object (with or without id) Upserted object
POST /employee/{id} UpdateHandler Partial object Updated object
DELETE /employee/{id} DeleteHandler Deleted object
GET /employee/export.csv CsvHandler CSV stream
GET /employee/export.xlsx ExcelHandler Excel binary

Common error responses#

Status Meaning Typical cause
400 Bad Request Validation failure Missing mandatory field, pattern mismatch, malformed JSON
401 Unauthorized Not authenticated Missing or expired session / token
403 Forbidden ACL denied @Permission check failed or aclCheck returned -1
404 Not Found No matching row Read / update / delete with an id that doesn’t exist
500 Internal Server Error Server-side failure Unhandled exception in a handler hook, DB connectivity issue

Notes for mobile / third-party integrators#

  1. Authentication. The API contract is auth-agnostic — it works with session cookies, Bearer tokens, Basic auth, or any scheme your Spring Security config handles. See AuthProvider for how the backend resolves the current user.

  2. Content negotiation. Always send Content-Type: application/json on POST / PUT bodies. The API does not support XML, form-urlencoded, or multipart for handler endpoints (file uploads go through TusUploadHandler instead).

  3. Pagination strategy. Use _offset / _limit for page-based navigation. To implement infinite scroll, increment _offset by _limit on each load. Request _total=true only on the first page (or when a “showing X of Y” label is needed) — the count query is skipped otherwise.

  4. Partial updates. POST /employee/{id} accepts a partial body — omitted fields are preserved. This is safe for mobile clients with intermittent connectivity: send only the changed fields.

  5. Foreign keys. Always set FKs by sending the referenced object’s primary key: "department": { "id": 2 }. To clear a nullable FK, send "department": null.

  6. Dates. Date fields serialize as ISO 8601 strings — YYYY-MM-DD for dates, YYYY-MM-DDTHH:mm:ssZ for timestamps. Parse them with any standard date library.

  7. Field projection. If bandwidth matters (mobile on cellular), use _fields=id,firstName,lastName,status to trim the payload to only the columns the screen needs.

  8. Export from mobile. CSV/Excel export endpoints return a file — open them in a system browser or download manager, not an in-app HTTP client that expects JSON.