Serial custom fields are what staff record about a unit when it goes out and comes back: an odometer or hour meter, a fuel level, a yes / no check, photos. Everything the admin screens do with them is available through GraphQL, so a handheld app at the yard, a telematics feed or a warehouse system can record readings and photos, and a dashboard can read usage back.
These are back-office operations: every one needs an admin or integration bearer token, obtained as described in GraphQL API Overview & Authentication, whose role holds the permission named below.
| Permission | Covers |
|---|---|
SalesIgniter_Rental::serial_fields | Field sets and fields: listing, creating, changing, giving them to products. |
SalesIgniter_Rental::serial_readings | A serial’s values and history, recording readings outside a rental, corrections and voids, a serial’s own set overrides, a reservation’s usage. |
SalesIgniter_Rental::send / ::return | Sending and returning with readings, and returning by scan. |
SalesIgniter_Rental::serial_photos | Viewing and uploading photos, trashing your own. |
SalesIgniter_Rental::serial_photos_manage | Deleting photos permanently and keeping them as evidence. |
Values are strings #
Every value travels as a string, in one convention everywhere:
| Field type | Send | Example |
|---|---|---|
meter, number | A plain decimal with a dot and no grouping. | "48902", "12.5" |
choice | The option’s key (its label is accepted too). | "half" |
yesno | "true" or "false". | "true" |
text | The text. | "Bucket 300 mm" |
Where it helps, typed copies come back next to the string (value_number, value_boolean), and display is the value formatted for a person in the store’s number format, such as 48,902 mi or ½. display wraps a number and its unit in invisible Unicode isolation marks (U+2066 and U+2069) so it reads correctly in right-to-left languages; strip them if you compare or parse it. Parse value, never display.
Field sets #
query FieldSets {
rentalSerialFieldSets {
id
slug
name
fields { key label type unit capture required options { key label } }
stats { products readings }
}
rentalSerialFieldTemplates { slug name industry }
}
rentalSerialFieldSets takes status (active, the default, archived or any); rentalSerialFieldSet(id:) or (slug:) fetches one. Each field’s capture says where it is asked for (send, return) and required where it must be filled in or skipped with a reason.
Start from one of the 14 templates, add a field and give the set to a product:
mutation StartFromATemplate {
createRentalSerialFieldSetFromTemplate(input: {
template: "condition-check"
name: "Tent condition"
}) {
set { id slug fields { key label type capture required } }
}
}
mutation AddAField {
addRentalSerialField(set_id: 295, input: {
label: "Pegs counted"
type: "number"
capture: ["send", "return"]
required: ["return"]
min: 0
max: 200
hint: "Count them into the bag"
}) {
field { key label type capture required }
}
}
mutation GiveItToAProduct {
setRentalProductSerialFieldSets(sku: "PARTY-TENT", set_ids: [295]) {
product_id
sets { id name fields { key } }
}
}
The key is derived from the label when you leave it out (pegs_counted). setRentalProductSerialFieldSets replaces the product’s whole list; an empty list removes every set. setRentalSerialFieldSetOverrides(serial_id:, include:, exclude:) gives one unit a set of its own or turns one of its product’s sets off for it.
updateRentalSerialFieldSet renames, archives (status: "archived") or restores a set, and updateRentalSerialField / archiveRentalSerialField do the same for a field. Once a field has readings its key, type and unit are locked and its decimals can only go up (field_has_readings), and a set or field with readings cannot be deleted (has_readings): archive it instead.
A serial’s values and history #
query OneVan {
rentalSerialNumber(id: 2241) {
serial_number
status
custom_field_sets { name source }
custom_fields { field label value display lifetime recorded_at }
custom_field_history(pageSize: 5) {
total_count
items { id field display kind context delta reservation_id recorded_at voided note }
}
photos {
total_count
items { id context note uploaded_by_name content(size: "thumb") { mime bytes base64 } }
}
}
}
"custom_fields": [
{ "field": "odometer", "label": "Odometer", "value": "48551", "display": "48,551 mi",
"lifetime": 341, "recorded_at": "2026-09-24T12:44:42+05:30" },
{ "field": "fuel", "label": "Fuel", "value": "f", "display": "F",
"lifetime": null, "recorded_at": "2026-09-24T12:44:42+05:30" }
]
custom_fields(context: "send")or(context: "return")lists only the fields asked for then, which is what a send or return screen of your own should show. Each carries its wholedefinition: limits, options, required.lifetimeis everything a meter has counted since its first reading, roll-overs and replaced meters included.custom_field_historyis the append-only log, newest first. Filter it withfield,context(send,return,manual,inspection,service),reservation_id, orinclude_voided: false.kindisreading,baseline(a meter’s first),rollover,meter_swap,correction,serviceorskipped, anddeltais how far a meter moved since the reading before.photoslists attached photos by default;statuscan ask forpending,trashed,deletedorany.content(size: "thumb")(320 px) orcontent(size: "master")(2048 px) returns the image itself, base64-encoded JPEG.
Sending and returning with readings #
sendRentalReservationSerials and returnRentalReservationSerials move units on one reservation together with their readings and photos. Everything is checked first and written in one transaction: if one reading is refused, nothing moves. The existing sendRentalSerials and returnRentalSerials, described in Sending & Returning Rentals, carry no readings and are unchanged.
mutation SendWithReadings {
sendRentalReservationSerials(input: {
reservation_id: 10484
serials: ["VAN-11"]
readings: [
{ serial: "VAN-11", field: "odometer", value: "5120" }
{ serial: "VAN-11", field: "fuel", value: "f" }
]
strict_readings: true
}) {
move_id
qty
readings { serial field value display }
warnings { code message }
reservation { qty_shipped }
}
}
strict_readings decides what a missing required field does. With true it refuses the whole move (reading_required, photo_required), the way the admin screens do. With false, the default, the move is recorded and each gap comes back in warnings as reading_missing, so an integration that syncs warehouse state does not start failing the day someone makes a field required.
Besides value, a reading can carry:
skip: "Display dead"in place of the value: a reason, which satisfies a required field;event: "rollover"(the meter wrapped at itsrollover_at) orevent: "meter_swap"(the meter was replaced, with optionalold_finalandnew_start), with a meter reading lower than the last one;- a
note.
Photos #
Upload each photo first, as pending, then attach it by id to the move:
mutation UploadAPhoto($data: String!) {
uploadRentalSerialPhoto(input: {
serial_id: 2241
base64: $data
context: "return"
note: "Scratch on the sliding door"
pending: true
client_ref: "return-van07-door-1"
}) {
photo { id status width height }
replayed
}
}
mutation ReturnWithReadings {
returnRentalReservationSerials(input: {
reservation_id: 10480
serials: ["VAN-07"]
readings: [
{ serial: "VAN-07", field: "odometer", value: "48902" }
{ serial: "VAN-07", field: "fuel", value: "half" }
{ serial: "VAN-07", field: "interior_clean", value: "true" }
]
photos: [{ serial: "VAN-07", id: 97 }]
}) {
move_id
readings { serial field display usage basis out }
photo_ids
warnings { code message }
}
}
"readings": [
{ "serial": "VAN-07", "field": "odometer", "display": "48,902 mi", "usage": 351, "basis": "send", "out": null },
{ "serial": "VAN-07", "field": "fuel", "display": "½", "usage": null, "basis": null, "out": "f" },
{ "serial": "VAN-07", "field": "interior_clean", "display": "Yes", "usage": null, "basis": null, "out": null }
],
"photo_ids": [97]
On a return, usage is how far each meter moved during the hire, and basis says how it was measured: send (from the reading taken at send, exact) or previous_reading (no send reading, so from the reading before, an estimate). out is the value a choice went out with.
Photos may be JPEG, PNG or WebP (HEIC where the server can read it), up to 15 MB and 40 megapixels; a data: URI prefix is accepted. They are stored as a 2048 px master and a 320 px thumbnail. A pending photo that is never attached is removed after 24 hours. client_ref makes an upload safe to retry: sending the same reference again returns the first photo with replayed: true.
updateRentalSerialPhoto changes a note or sets keep_evidence; trashRentalSerialPhoto and restoreRentalSerialPhoto move a photo in and out of the 30-day trash; deleteRentalSerialPhoto(id:, reason:) deletes one permanently, keeping a tombstone with who, when and why.
Returning by scan #
returnRentalSerialByScan takes exactly what a scanner typed and finds the one reservation the unit is out on. Call it with dry_run: true first to learn what to ask for:
mutation PreviewAScan {
returnRentalSerialByScan(input: { code: "VAN-11", dry_run: true }) {
order
outstanding
fields { field label required last { display date } sent { display date } }
}
}
last is the latest value and sent the value at send: show them as hints, but never send them back unread, because a copied odometer records a hire of zero miles. Then send the same call without dry_run and with the values:
mutation ReturnByScan {
returnRentalSerialByScan(input: {
code: "VAN-11"
readings: [
{ field: "odometer", value: "5388" }
{ field: "fuel", value: "q3" }
{ field: "interior_clean", skip: "Not checked, customer in a hurry" }
]
}) {
message
outstanding
move { readings { field display usage } }
}
}
"message": "Returned VAN-11 on order 000000677. Nothing left out on this line.",
"outstanding": 0
Pass order (an increment id) to refuse a unit that belongs to a different order (wrong_order). The other refusals are empty_code, serial_not_found, ambiguous_serial, serial_not_out, no_reservation, already_returned and nothing_outstanding.
Recording outside a rental #
Yard checks, services and telematics feeds use recordRentalSerialReadings:
mutation YardCheck {
recordRentalSerialReadings(input: {
serial_id: 2241
context: "inspection"
values: [
{ field: "odometer", value: "48910" }
{ field: "fuel", value: "f" }
]
note: "Yard check before the weekend"
client_ref: "yard-2026-09-24-van07"
}) {
replayed
readings { id field display delta kind }
warnings { code message }
}
}
context is manual (the default), inspection or service. recorded_at back-dates the readings (ISO 8601; they must fit between the readings around them and can never be in the future), and reservation_id ties them to a hire, such as a mid-hire check. client_ref makes the call idempotent: repeating it returns the readings first written with it, with replayed: true, instead of recording them twice, which is what a feed that retries after a timeout needs.
Corrections and voids #
The log is append-only. A correction is a new reading that replaces the original at the same moment; the original is voided with your reason:
mutation FixATypo {
correctRentalSerialReading(reading_id: 1388, value: "48901", reason: "Misread the dashboard") {
reading { id value display kind supersedes_id }
original { id voided void_reason }
}
}
voidRentalSerialReading(reading_id:, reason:) voids a reading that should not exist at all. Either way, usage and current values are worked out again.
What a hire did #
serial_usage on a reservation lists each unit and field with its value at send and at return, and each meter’s usage over the hire:
query WhatDidTheHireDo {
rentalReservation(id: 10480) {
serial_usage { serial field unit usage basis send_display return_display }
}
}
"serial_usage": [
{ "serial": "VAN-07", "field": "odometer", "unit": "mi", "usage": 350, "basis": "send",
"send_display": "48,551 mi", "return_display": "48,901 mi" },
{ "serial": "VAN-07", "field": "fuel", "unit": null, "usage": null, "basis": null,
"send_display": "F", "return_display": "½" }
]
While a unit is still out, return_display is null and the usage runs to the latest reading.
Changes to serial numbers #
Two rules from the admin now apply to the serial number fields too:
- A serial’s code cannot be changed once the unit has been sent or returned, or put on a maintenance ticket. Those records name the unit by its code, so
updateRentalSerialNumberrefuses the rename withserial_rename_has_historyand the counts inextensions.data(reservations,tickets). Add the new code as a new serial and retire the old one. deleteRentalSerialNumberrefuses a unit that has custom field readings or photos withserial_has_readingsand the counts (readings,photos). Sendforce: trueto delete it together with them.forcenever overrides the refusal for a unit that is out or held by a maintenance ticket.
Errors #
A refusal comes back as a GraphQL error whose extensions carry a machine-readable code, the HTTP status the same refusal would have in the admin (http_status), and the details in data:
{
"errors": [{
"message": "Odometer for VAN-11 (512 mi) is lower than its last reading (5,120 mi). If the meter was replaced, record that event.",
"path": ["returnRentalReservationSerials"],
"extensions": {
"category": "graphql-input",
"code": "reading_decreased",
"http_status": 409,
"data": { "field": "odometer", "last_value": 5120, "last_recorded_at": "2026-09-24T08:25:37Z", "serial_code": "VAN-11" }
}
}]
}
| Code | Meaning |
|---|---|
reading_decreased | A meter reading lower than the last one, without a rollover or meter_swap event. |
reading_out_of_range | Outside the field’s min / max. |
reading_out_of_sequence | A back-dated reading that does not fit between the readings around it. |
recorded_in_future | recorded_at is in the future. |
reading_required, photo_required | A required field neither filled in nor skipped, with strict_readings: true. |
unknown_reading_field | No field has that key. |
reading_not_applicable | A field the unit does not record in this context, or a typed value for a photo field. |
reading_without_serial | Readings or photos for a serial that is not one of the move’s serials. |
field_key_conflict | Two sets would give one serial two fields with the same key. |
field_has_readings, has_readings | A field or set with readings cannot be changed that way or deleted: archive it. |
serial_rename_has_history | See above. |
serial_has_readings | See above; send force: true. |
reason_required | A skip, correction, void or photo deletion needs a reason. |
Two things come back as warnings rather than errors, because they should never stop a unit from moving: reading_missing (a required field left out with strict_readings: false) and implausible_reading (a meter moved more per day than the field’s max_per_day).
