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=trueQuery 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
departmentsub-object is included because the model declares it as a nested FK. Its depth is controlled by@FetchConfig— in this example,PRIMITIVE_OR_KEY_FIELDSreturns id + scalar columns but no further nested objects. departmentCodeis a flattened shortcut viaparentRef— it carries the same value asdepartment.codebut 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.CREATEormandatory = Mandatory.ALLmust 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
idfield (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.UPDATEormandatory = Mandatory.ALLmust 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/jsonSame 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 path — loginName matches an existing row (because preferredKey = "loginName"):
{
"loginName": "ada@example.com",
"lastName": "Lovelace-Byron"
}How the lookup works:
- Palmyra checks the primary key (
id) in the payload — if present and a row matches, it’s an update. - If no
id(oriddoesn’t match), it checks thepreferredKey— in this example,loginName. - If
preferredKeyisn’t set, it checks all declared unique keys (OR’d together). - 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/102No 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=-joiningDateSame 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-15Column 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=firstNameResponse — 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#
-
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
AuthProviderfor how the backend resolves the current user. -
Content negotiation. Always send
Content-Type: application/jsonon POST / PUT bodies. The API does not support XML, form-urlencoded, or multipart for handler endpoints (file uploads go throughTusUploadHandlerinstead). -
Pagination strategy. Use
_offset/_limitfor page-based navigation. To implement infinite scroll, increment_offsetby_limiton each load. Request_total=trueonly on the first page (or when a “showing X of Y” label is needed) — the count query is skipped otherwise. -
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. -
Foreign keys. Always set FKs by sending the referenced object’s primary key:
"department": { "id": 2 }. To clear a nullable FK, send"department": null. -
Dates. Date fields serialize as ISO 8601 strings —
YYYY-MM-DDfor dates,YYYY-MM-DDTHH:mm:ssZfor timestamps. Parse them with any standard date library. -
Field projection. If bandwidth matters (mobile on cellular), use
_fields=id,firstName,lastName,statusto trim the payload to only the columns the screen needs. -
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.