View Categories

Serial Custom Fields, Readings & Photos

11 min read

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.

PermissionCovers
SalesIgniter_Rental::serial_fieldsField sets and fields: listing, creating, changing, giving them to products.
SalesIgniter_Rental::serial_readingsA 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 / ::returnSending and returning with readings, and returning by scan.
SalesIgniter_Rental::serial_photosViewing and uploading photos, trashing your own.
SalesIgniter_Rental::serial_photos_manageDeleting photos permanently and keeping them as evidence.

Values are strings #

Every value travels as a string, in one convention everywhere:

Field typeSendExample
meter, numberA plain decimal with a dot and no grouping."48902", "12.5"
choiceThe option’s key (its label is accepted too)."half"
yesno"true" or "false"."true"
textThe 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 whole definition: limits, options, required.
  • lifetime is everything a meter has counted since its first reading, roll-overs and replaced meters included.
  • custom_field_history is the append-only log, newest first. Filter it with field, context (send, return, manual, inspection, service), reservation_id, or include_voided: false. kind is reading, baseline (a meter’s first), rollover, meter_swap, correction, service or skipped, and delta is how far a meter moved since the reading before.
  • photos lists attached photos by default; status can ask for pending, trashed, deleted or any. content(size: "thumb") (320 px) or content(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 its rollover_at) or event: "meter_swap" (the meter was replaced, with optional old_final and new_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 updateRentalSerialNumber refuses the rename with serial_rename_has_history and the counts in extensions.data (reservations, tickets). Add the new code as a new serial and retire the old one.
  • deleteRentalSerialNumber refuses a unit that has custom field readings or photos with serial_has_readings and the counts (readings, photos). Send force: true to delete it together with them. force never 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" }
    }
  }]
}
CodeMeaning
reading_decreasedA meter reading lower than the last one, without a rollover or meter_swap event.
reading_out_of_rangeOutside the field’s min / max.
reading_out_of_sequenceA back-dated reading that does not fit between the readings around it.
recorded_in_futurerecorded_at is in the future.
reading_required, photo_requiredA required field neither filled in nor skipped, with strict_readings: true.
unknown_reading_fieldNo field has that key.
reading_not_applicableA field the unit does not record in this context, or a typed value for a photo field.
reading_without_serialReadings or photos for a serial that is not one of the move’s serials.
field_key_conflictTwo sets would give one serial two fields with the same key.
field_has_readings, has_readingsA field or set with readings cannot be changed that way or deleted: archive it.
serial_rename_has_historySee above.
serial_has_readingsSee above; send force: true.
reason_requiredA 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).