Skip to content

Housekeeping tasks and maintenance tickets

The workflow contract of housekeeping work and maintenance tickets. The decisions and their reasons are in ADR 0046; room blocks are in ADR 0033 and the room status itself in reservations. Every path is under /api/v1/properties/{property_id} and answers in the workspace of the GuestCraft-Workspace-Id header.

Action Permission Built-in roles
Read tasks, generated days and settings inventory.read every role
Start, finish, skip and reopen tasks; edit a task’s note housekeeping.update administrators, front desk, housekeeping
Read rosters, credits, checklist templates, a task’s checklist and the day’s automatic assignments inventory.read every role
Answer a task’s checklist items housekeeping.update administrators, front desk, housekeeping
Generate a day, create manual tasks, assign and reassign, change due date, priority and pin, plan a day’s roster, preview, apply and undo automatic assignment housekeeping.assign administrators, front desk, housekeeping
Change the property’s housekeeping settings, credits and checklist templates housekeeping.configure administrators
Read tickets, their activity and photos maintenance.read every role
Report faults, edit, assign, change status, add and remove photos maintenance.manage administrators, front desk, housekeeping
Put the room out of order or out of service with a report blocks.manage_ooo or blocks.manage_oos as for blocks

A task can be given only to a member who holds housekeeping.update at that property now; a ticket only to one who holds maintenance.manage there (housekeeping.assignee_invalid, ticket.assignee_invalid). GET /housekeeping_assignees and GET /maintenance_assignees list them. Members limited to some properties see and assign only theirs.

GET /housekeeping_settings answers {stayover, stayover_every_nights, inspection_required, version} with its ETag; a property that saved none has stayover service off, no inspection and version 1. PUT /housekeeping_settings takes the ETag in If-Match.

  • stayover: off, daily or every_n_nights with stayover_every_nights from 2 to 30, counted from the arrival (every 3 nights: the third, sixth… day of the stay).
  • inspection_required: a finished clean leaves the room clean and creates an inspection task; only an inspected room is ready for check-in. Without it, clean is ready.

Saving re-synchronizes the generated days from the business date on.

Type Made by Room Due
departure_clean a stay that leaves the room of its last night departure date, or the check-out day after an early departure
move_clean a planned room move, also a future one the room the guest leaves the first night in the new room
stayover the stayover setting the room of that night each service day of the stay, never the arrival day or a move day
clean, deep_clean, inspection, turndown, other a person (POST /housekeeping_tasks) any active room business date to one year ahead
inspection a finished clean when inspection is required the cleaned room the clean’s day, or the business date for an overdue clean
clean or inspection a closed maintenance ticket the ticket’s room the business date

Statuses: pending, in_progress, done, skipped (with a reason such as “Do not disturb”) and cancelled. People move tasks freely between the first four with PUT /housekeeping_tasks/{id}/status and the task’s ETag, so Undo is the inverse change; cancelled belongs to generation and refuses changes (housekeeping.invalid_state).

Effects on the room status:

  • finishing a clean sets the room clean (and creates the inspection when required);
  • finishing an inspection sets it inspected;
  • reopening a finished task restores the room’s previous status if the room still has the status the task gave it, and withdraws its pending inspection;
  • skipping, starting and turndown or other tasks leave the room as it is.

POST /housekeeping_days with {date} generates a day, from the business date to 30 days after it: 201 the first time, 200 when run again. It is idempotent: a second run adds only what the stays now owe and cancels pending tasks they no longer owe. Each run gives the day its next version, answered as its ETag. GET /housekeeping_days/{date} answers a generated day with when it was first generated and its version; a day never generated answers 404 housekeeping.day_not_found, which the board shows as “not generated yet”.

After that, every reservation change reschedules the reservation’s tasks in the same transaction, whoever made it (front desk, planning, policies, OTA import):

  • a new date or room moves the pending task (same task, new due_on or unit_id);
  • a shortened stay, an undone move, a cancellation or a no-show cancels its pending tasks;
  • tasks that have started, finished or were skipped are never changed;
  • a new task appears only on a generated day.

The database refuses a second task for the same stay fact (origin_key, unique per property).

GET /housekeeping_tasks?from=YYYY-MM-DD[&to=…] lists up to 31 days (default: one day), with the filters status (comma-separated), type, assignee_id (usr_… or me), unassigned=true and unit_id; without status, every status is listed, cancelled tasks included. Tasks come in board order: due date, arrivals first (a guest is expected in the room that day: arrival_expected), priority (high, normal, low), then the room order.

POST /housekeeping_task_status_changes and POST /housekeeping_task_assignments change 1 to 100 distinct tasks, each with the version the client read instead of an If-Match, all or none: one missing task, stale version (412) or refused transition changes nothing. A repeated request after a success finds the new versions and answers 412.

Creation accepts a client id (hkt_…): repeating the request answers the same task with 200 instead of creating another, so an offline client can resend safely. An id that a task of another property already uses is refused (housekeeping.invalid, field id) and changes nothing.

/maintenance_tickets holds faults of one room: title, notes, priority (low, normal, high, urgent), assignee, scheduled_on, due_on, the reporting member, the task it was found during (source_task_id) and a linked block.

  • Report a fault: POST /maintenance_tickets, optionally with block: {kind, from, to, reason} to put the room out of order or out of service in the same transaction under the block rules, and with a client id (mtk_…) for safe resending (the id of another property’s ticket is ticket.invalid, field id; nothing is written). A report that creates a block also answers overbooked_dates, the nights it oversells the room type, as creating a block does.
  • Edit: PATCH /maintenance_tickets/{id} with the ETag.
  • Status: PUT /maintenance_tickets/{id}/status moves between open, in_progress and done. Closing requires follow_up: "clean" | "inspection", which creates that housekeeping task for the room; the room status never changes, as the analysis requires. Reopening withdraws the follow-up while it is pending.
  • Activity: GET /maintenance_tickets/{id}/activity, oldest first: who did what and when, never the text.

POST /maintenance_tickets/{id}/photos takes {id?, content_type, base64}: a JPEG or PNG of at most 2,000,000 bytes (photo.too_large, also for base64 longer than such a photo’s), in a request body of at most 3 MiB, 10 photos per ticket. The server checks that the bytes are a structurally valid image of the declared type, by its grammar, without decoding its compressed data (ADR 0046 lists every rule) (photo.invalid, field base64; an id another property’s photo uses is photo.invalid, field id), refuses a picture of more than 4096 pixels on a side or 16 megapixels (photo.dimensions_too_large), and removes location and other metadata (Exif, XMP, IPTC, comments, PNG text chunks) before storing it. Clients downscale before sending; the web and mobile apps send a JPEG of at most 1,600 pixels.

GET /maintenance_tickets/{id}/photos/{photo_id} answers the image with Cache-Control: private, max-age=300, X-Content-Type-Options: nosniff, Content-Disposition: inline and ETag: "1" (a photo never changes), to members who can read the ticket’s property.

The photos are part of the ticket: adding a new one (not a repeated upload) or removing one gives the ticket its next version, which both answer as ETag. DELETE removes a photo and takes the ticket’s ETag in If-Match (ticket.version_mismatch, 412, with the current version).

Photos are stored files behind the FileStore port: in the database (stored_files) by default, or in an instance’s object storage (an S3-compatible bucket or a private Vercel Blob store) when it configures one. Downloads always go through this route and its permission check. A downscaled photo takes 150–400 kB, and free database tiers hold about 500 MB in total: see the installation guide and its object storage section.

A room’s floor is its section for housekeeping (the screens call it “Floor or section”).

  • GET/PUT /housekeeping_rosters/{date}: the members who work on a day, {staff: [{user_id, starts_at, capacity_credits, section}], version} with its ETag; a day nobody planned answers no staff at version 1. PUT takes the ETag in If-Match and replaces the list: at most 100 distinct members who can be given tasks there now (housekeeping.assignee_invalid), each with a start time (HH:MM), a capacity of 1 to 999 credits and an optional section (a floor or section name). Days from the business date to 30 days after it (housekeeping.invalid, field date).
  • GET/PUT /housekeeping_credits: {rules: [{task_type, room_type_id, credits}], version}, how many credits (0 to 100) a task type takes in rooms of a room type, or of any room type when room_type_id is null; at most 200 rules, each pair once. A type without a rule takes its default: departure_clean and move_clean 3, deep_clean 5, clean 2, the others 1.

GET /housekeeping_assignment_plans/{date} previews, without writing, how the day’s work would be distributed over its roster:

  • changes: each task that would get a new assignee, with its version, from whom and to whom, and its credits;
  • unassigned: each task nobody has room for, with the reason no_staff (nobody available on the roster) or no_capacity; a task automatic assignment gave earlier is also in changes, taken back to nobody (to_assignee_id: null);
  • staff: per member of the roster, whether they can still be given tasks there (available; an unavailable member gets nothing), and the credits of the work they keep and of the work planned;
  • basis: what applying would write, to send back when applying.

Automatic assignment moves only pending tasks that are not pinned and are unassigned or assigned automatically. A person’s assignment, a pinned task and work already begun stay as they are; their credits count against their assignee. Each movable task, arrivals first, goes to a member with enough capacity left: one of its section first, then one without a section, preferring a section the member already works in, the earliest start for an arrival room, and the lightest load. The same tasks, roster and credits always give the same plan.

POST /housekeeping_assignment_runs with {date, basis} applies the previewed plan in one audited operation and answers the run (201): its changes with each task’s previous assignee. A plan that changed since the preview answers housekeeping.plan_changed (409): preview again. A plan with no change answers housekeeping.plan_empty (409).

POST /housekeeping_assignment_runs/{id}/undo restores every task of the run to its previous assignee, exactly, or nothing: it answers housekeeping.undo_conflict (409) when the run was undone already, when a task of it changed since, or when a previous assignee can no longer be given tasks there. GET /housekeeping_assignment_runs?date= lists the day’s runs, newest first, and GET /housekeeping_assignment_runs/{id} one run.

Assigning a task by hand, also to its current assignee, makes it a manual assignment that automatic assignment keeps. PATCH /housekeeping_tasks/{id} with pinned: true keeps a task as it is, assigned or not.

/housekeeping_checklists holds the property’s templates: {name, task_type, room_type_id, items: [{key, label, kind, required}], version}. A template applies to tasks of its type in rooms of its room type, or of any room type when room_type_id is null; a property has one template per pair (housekeeping.checklist_duplicate, 409). Items are 1 to 50, each with a key (a-z, 0-9, _, up to 40, unique in the template), a label of up to 200 characters, a kind (check: ticked when done; inspection: passed or failed) and whether it is required. PUT replaces the name and items as the next version, DELETE removes the template; both take its ETag. The task type and room type of a template do not change.

When a task is created, the template that applies then is copied onto it: its checklist says which version, with total, answered and required_open items. Later edits never change the tasks that exist.

  • GET /housekeeping_tasks/{id}/checks lists the task’s items with their answers.
  • PUT /housekeeping_tasks/{id}/checks/{key} with the task’s ETag answers one: checked (a check item), passed or failed (an inspection item), or null to clear it (housekeeping.invalid, field result), only while the task is pending or in progress (housekeeping.invalid_state). A failed item without follow-up work needs follow_up: {kind: "task", type, note} (clean, deep clean, inspection or other, due on the task’s day or the business date if later) or {kind: "fault", title, priority} (a maintenance ticket found during the task, which also needs maintenance.manage). The follow-up is created in the same transaction and stays when the answer changes later. The task gets its next version.
  • A task whose required items are not all answered cannot be done (housekeeping.checklist_incomplete, 409), alone or in a bulk change. A failed item is answered.

Tasks publish three events of the catalog, with the object housekeeping_task (the task, its property and, for a task a stay made, its reservation):

Event When
housekeeping_task.created@1 a task is created: generated, by a person, an inspection after a clean, a closed ticket or a failed checklist item
housekeeping_task.assigned@1 its assignee changes: by a person, in bulk, by automatic assignment or its Undo; also a task a person creates for a member, after its created
housekeeping_task.status_changed@1 its status changes, also a cancellation or reinstatement that follows its stay

A task moved to another date or room by its stay keeps its status and publishes none of them.

Push notifications and checklists in the mobile app are not included. Offline, the mobile app queues task status, notes and fault reports with their photos in the outbox of its offline layer and sends them on reconnection (ADR 0041, ADR 0046).