{
  "openapi": "3.1.0",
  "info": {
    "title": "Muutto365 public API",
    "version": "1.0.0",
    "summary": "Automated pricing, scheduling and booking for moving and waste-removal jobs in Finland.",
    "description": "Every endpoint here is public and anonymous — there is no API key and no account. Prices are in EUR and include Finnish VAT at 25.5%. Dates are ISO 8601 and all times are Europe/Helsinki.\n\nPricing and scheduling are computed server-side. A client that sends its own price is ignored: /api/booking re-prices the job from the submitted parameters before writing it.\n\nAgents working inside a browser on muutto365.fi should prefer the in-page WebMCP tools, which drive this same API through the site's own validation and keep the visible form in step. See https://agent.muutto365.fi/.",
    "contact": {
      "name": "Muutto365 asiakaspalvelu",
      "email": "asiakaspalvelu@muutto365.fi",
      "url": "https://muutto365.fi/"
    },
    "termsOfService": "https://muutto365.fi/TOS.md",
    "license": { "name": "Proprietary", "identifier": "LicenseRef-Proprietary" }
  },
  "servers": [{ "url": "https://api.muutto365.fi", "description": "Production" }],
  "externalDocs": {
    "description": "Agent landing page: worked examples, scheduling rules and MCP onboarding",
    "url": "https://agent.muutto365.fi/"
  },
  "tags": [
    { "name": "addresses", "description": "Address resolution and routing." },
    { "name": "pricing", "description": "Price and duration estimation." },
    { "name": "scheduling", "description": "Arrival-window availability." },
    { "name": "booking", "description": "Agreement, booking creation and cancellation." },
    { "name": "nlp", "description": "Special-handling item detection in Finnish free text." },
    { "name": "poistopalvelu", "description": "Item removal and space clearing: VAT-inclusive pricing, quotes with restricted-item checks, agreement, booking, save-for-later and call requests." }
  ],
  "paths": {
    "/health": {
      "get": {
        "summary": "Liveness probe",
        "operationId": "health",
        "tags": ["addresses"],
        "responses": { "200": { "description": "Service is up." } }
      }
    },
    "/api/geocode": {
      "get": {
        "summary": "Resolve a Finnish address to coordinates",
        "description": "Results are cached for 24 hours. Rate limit: 20 requests/minute.",
        "operationId": "geocode",
        "tags": ["addresses"],
        "parameters": [
          { "name": "street", "in": "query", "required": true, "schema": { "type": "string" }, "example": "Väinönkatu 1" },
          { "name": "city", "in": "query", "required": true, "schema": { "type": "string" }, "example": "Jyväskylä" },
          { "name": "postcode", "in": "query", "required": false, "schema": { "type": "string" }, "example": "40100" }
        ],
        "responses": {
          "200": {
            "description": "Resolved coordinates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "lat": { "type": "number" }, "lon": { "type": "number" } },
                  "required": ["lat", "lon"]
                }
              }
            }
          },
          "404": { "description": "Address could not be resolved." },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/price": {
      "post": {
        "summary": "Estimate the price and duration of a move",
        "description": "Form-encoded on purpose: a simple request avoids the CORS preflight round-trip from the browser. Returns an itemised EUR breakdown and estimated crew hours. Rate limit: 120 requests/minute.\n\nSend from_lat/from_lon and to_lat/to_lon from /api/geocode when you have them — the server then resolves the real driving distance rather than falling back to an average-speed estimate.",
        "operationId": "estimatePrice",
        "tags": ["pricing"],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": { "$ref": "#/components/schemas/PriceEstimateRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Itemised estimate.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PriceEstimate" } } }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/timeslots": {
      "get": {
        "summary": "Arrival windows still available on a date",
        "description": "Returns the subset of [\"08-09\", \"12-13\", \"16-17\", \"20-21\"] that can still accommodate a job of the given estimated duration, in Europe/Helsinki. An empty list is a valid answer: the day is full, or the job no longer fits what is left of it. Rate limit: 30 requests/minute.",
        "operationId": "getTimeslots",
        "tags": ["scheduling"],
        "parameters": [
          { "name": "move_date", "in": "query", "required": true, "schema": { "type": "string", "format": "date" }, "description": "Today or up to 90 days ahead." },
          { "name": "estimated_duration", "in": "query", "required": true, "schema": { "type": "number" }, "description": "Estimated job duration in hours, from the price estimate's time_itemized.total." },
          { "name": "vertical", "in": "query", "required": false, "schema": { "type": "string", "enum": ["moving", "garbage"], "default": "moving" }, "description": "garbage = item removal (/api/poistopalvelu/*); pass that quote's duration_h as estimated_duration. A garbage response also carries shared_transport per day: whether booking that date earns Yhteiskuljetusetu. It is the plain fact about the date and never widens availability — a day with no available_windows cannot be booked, discount or not." }
        ],
        "responses": {
          "200": {
            "description": "Availability for the date.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "available_windows": { "type": "array", "items": { "$ref": "#/components/schemas/Timeslot" } },
                    "rejection_reason": {
                      "type": ["string", "null"],
                      "description": "\"overtime\" when the job exceeds the 12-hour automated-booking cutoff and must be arranged by contacting Muutto365."
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/nlp": {
      "post": {
        "summary": "Detect special-handling items in Finnish free text",
        "description": "Returns an empty array when the NLP service is unavailable — that is a normal answer, not an error. Items with blocking:true cannot be booked through the automated flow. Rate limit: 30 requests/minute.",
        "operationId": "detectItems",
        "tags": ["nlp"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "text": { "type": "string", "maxLength": 5000 },
                  "context": { "type": "string", "enum": ["heavy_items", "move_description", "lisatiedot"] }
                },
                "required": ["text", "context"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Detected items.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "detected_items": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": { "type": "string" },
                          "label_fi": { "type": "string" },
                          "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
                          "blocking": { "type": "boolean" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/bookings/agreement/preview": {
      "post": {
        "summary": "Render the moving agreement PDF",
        "description": "Returns the agreement the customer must accept, and an X-Idempotency-Key header identifying it. Retain that key: it is required by both /confirm and /api/booking.",
        "operationId": "previewAgreement",
        "tags": ["booking"],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": {
          "200": {
            "description": "Agreement PDF.",
            "headers": {
              "X-Idempotency-Key": { "schema": { "type": "string" }, "description": "Identifies this agreement across confirm and booking." }
            },
            "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } }
          }
        }
      }
    },
    "/api/bookings/agreement/confirm": {
      "post": {
        "summary": "Record acceptance of a previewed agreement",
        "description": "Call only after the customer has actually been shown the agreement and has accepted it. 409 means it was already accepted and is safe to treat as success.",
        "operationId": "confirmAgreement",
        "tags": ["booking"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "idempotency_key": { "type": "string" }, "accepted": { "type": "boolean" } },
                "required": ["idempotency_key", "accepted"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Acceptance recorded." },
          "409": { "description": "Already accepted." }
        }
      }
    },
    "/api/booking": {
      "post": {
        "summary": "Create a moving booking",
        "description": "The price is recomputed server-side from the submitted job; any price sent by the client is ignored. Requires the accepted agreement's idempotency key.\n\nA card must be on file for the agreement before this succeeds (stored via Stripe, not charged) — without it the request is refused with 403. This is an anti-fraud control on an otherwise anonymous flow.\n\nRate limit: 5 requests/hour.",
        "operationId": "createBooking",
        "tags": ["booking"],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BookingRequest" } } }
        },
        "responses": {
          "201": {
            "description": "Booking created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "booking_id": { "type": "string" } }
                }
              }
            }
          },
          "403": { "description": "No card on file for the agreement, or the agreement was not accepted." },
          "409": { "description": "The arrival window was taken while the request was in flight. Re-fetch /api/timeslots and retry." },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/cancel": {
      "post": {
        "summary": "Cancel a moving booking",
        "description": "Authorized entirely by the per-booking token from the confirmation email. Free more than 24 hours before the job; see the terms for later cancellations. Rate limit: 10 requests/hour.",
        "operationId": "cancelBooking",
        "tags": ["booking"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "token": { "type": "string", "description": "Cancellation token from the confirmation email." } },
                "required": ["token"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Booking cancelled." },
          "404": { "description": "Unknown or already-used token." },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/poistopalvelu/pricing": {
      "get": {
        "summary": "Item-removal size buckets and their VAT-inclusive base prices",
        "description": "The size buckets the page at https://muutto365.fi/poistopalvelu/ renders its cards from, plus the Yhteiskuljetusetu amount. The cards themselves print no price (2026-09-20): base_eur is a minimum — every job adds driving, and extra_large adds a price for the home's size (square_meters and rooms, from the moving pricing engine) — and it is stated in the price breakdown instead. Cached for 5 minutes.",
        "operationId": "getPoistoPricing",
        "tags": [
          "poistopalvelu"
        ],
        "responses": {
          "200": {
            "description": "Buckets.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PoistoPricing"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/quote": {
      "post": {
        "summary": "Price an item removal",
        "description": "Any subset of the draft. Without pickup, driving_eur is null and total_eur is base + size_extra_eur (a minimum). For extra_large, size_extra_eur is the price for the home's size from square_meters and rooms. Send service_date to have Yhteiskuljetusetu decided: 5 € off when another removal collection is already booked for that date (price.discount_eur, already subtracted). Eligibility is a property of the date alone — this endpoint is never told who is asking, and the answer is the same for every caller. Restricted items are checked on every call from items_description. Rate limit: 60 requests/minute.",
        "operationId": "quotePoisto",
        "tags": [
          "poistopalvelu"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PoistoQuoteRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quote.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PoistoQuote"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/agreement/preview": {
      "post": {
        "summary": "Render the item-removal agreement PDF",
        "description": "The server re-quotes the full draft; the PDF carries the server's numbers. Consents are not required here. Retain X-Idempotency-Key for /booking, /save-for-later or /call-requested.",
        "operationId": "previewPoistoAgreement",
        "tags": [
          "poistopalvelu"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PoistoDraft"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Agreement PDF.",
            "headers": {
              "X-Idempotency-Key": {
                "schema": {
                  "type": "string"
                },
                "description": "Identifies this agreement."
              },
              "X-Quote-Total-Eur": {
                "schema": {
                  "type": "number"
                },
                "description": "The VAT-inclusive total the PDF states."
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/RouteUnavailable"
          },
          "422": {
            "$ref": "#/components/responses/PoistoUnprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/booking": {
      "post": {
        "summary": "Book an item removal",
        "description": "Without agreement_idempotency_key the server renders, stores and accepts the agreement in the same transaction (the consents are the acceptance) and emails the PDF. With a key it must match the re-rendered draft. No card is taken. A booking the routing graph wants reviewed is accepted as pending_review.",
        "operationId": "createPoistoBooking",
        "tags": [
          "poistopalvelu"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/PoistoDraft"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "agreement_idempotency_key": {
                        "type": "string",
                        "description": "X-Idempotency-Key from /api/poistopalvelu/agreement/preview."
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booking created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "confirmed",
                        "pending_review"
                      ]
                    },
                    "booking_id": {
                      "type": "string"
                    },
                    "total_eur": {
                      "type": "number",
                      "description": "VAT included."
                    },
                    "arrival_window": {
                      "type": "object",
                      "properties": {
                        "start_utc": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "end_utc": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Told apart by detail.code: slot_taken — the arrival window was taken meanwhile: re-fetch /api/timeslots?vertical=garbage and pick again; job_too_long — the job cannot be booked online at all (capacity/overtime): send the same draft to /api/poistopalvelu/call-requested instead, which takes it without holding a slot; agreement_mismatch — the draft no longer matches the previewed agreement: preview again, or book without a key. A detail string is sent when the agreement was already booked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PoistoConflictError"
                }
              }
            }
          },
          "404": {
            "description": "agreement_idempotency_key is unknown for this customer email."
          },
          "503": {
            "$ref": "#/components/responses/RouteUnavailable"
          },
          "422": {
            "$ref": "#/components/responses/PoistoUnprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/save-for-later": {
      "post": {
        "summary": "Keep the offer: hold the slot and email a link to confirm later",
        "description": "Creates a reserved hold. Expiry is the earlier of 72 hours and two days before service_date.",
        "operationId": "savePoistoForLater",
        "tags": [
          "poistopalvelu"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/PoistoDraft"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "agreement_idempotency_key": {
                        "type": "string",
                        "description": "X-Idempotency-Key from /api/poistopalvelu/agreement/preview."
                      }
                    },
                    "required": [
                      "agreement_idempotency_key"
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "detail.code agreement_mismatch when the draft no longer matches the previewed agreement, slot_taken when the window was taken meanwhile, job_too_long when the job cannot be booked online (use /call-requested); a detail string containing \"liian lähellä\" when the service date is too close to hold the offer, or when the agreement was already booked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PoistoConflictError"
                }
              }
            }
          },
          "404": {
            "description": "agreement_idempotency_key is unknown for this customer email."
          },
          "503": {
            "$ref": "#/components/responses/RouteUnavailable"
          },
          "422": {
            "$ref": "#/components/responses/PoistoUnprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/resume": {
      "get": {
        "summary": "A saved offer, by its resume token",
        "operationId": "getPoistoResume",
        "tags": [
          "poistopalvelu"
        ],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Resume token from the \"Ota tarjous talteen\" email."
          }
        ],
        "responses": {
          "200": {
            "description": "Saved offer.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "reserved"
                      ]
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "total_eur": {
                      "type": "integer",
                      "description": "Frozen when saved; VAT included."
                    },
                    "draft": {
                      "type": "object",
                      "properties": {
                        "size_bucket": {
                          "$ref": "#/components/schemas/PoistoSizeBucket"
                        },
                        "size_label_fi": {
                          "type": "string"
                        },
                        "square_meters": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "rooms": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "customer_type": {
                          "type": "string",
                          "enum": [
                            "household",
                            "business"
                          ]
                        },
                        "items_description": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "photos_provided": {
                          "type": "boolean"
                        },
                        "pickup": {
                          "type": "object",
                          "properties": {
                            "street": {
                              "type": "string"
                            },
                            "postal_code": {
                              "type": "string"
                            },
                            "city": {
                              "type": "string"
                            }
                          }
                        },
                        "service_date": {
                          "type": "string",
                          "format": "date"
                        },
                        "timeslot": {
                          "$ref": "#/components/schemas/Timeslot"
                        },
                        "timeslot_label": {
                          "type": "string"
                        },
                        "customer": {
                          "type": "object",
                          "properties": {
                            "name": {
                              "type": "string"
                            }
                          }
                        }
                      }
                    },
                    "price": {
                      "type": "object",
                      "description": "The frozen breakdown, VAT included.",
                      "properties": {
                        "base_eur": {
                          "type": "integer"
                        },
                        "driving_eur": {
                          "type": "integer"
                        },
                        "size_extra_eur": {
                          "type": "integer"
                        },
                        "total_eur": {
                          "type": "integer"
                        },
                        "vat_rate_pct": {
                          "type": "number"
                        },
                        "vat_included_eur": {
                          "type": "number"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Unknown, expired or already used token."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "description": "The offer held by \"Ota tarjous talteen\". Emailed links open https://muutto365.fi/poistopalvelu/?resume=<token>. Every failure — unknown, expired or already used — is the same 404, deliberately."
      }
    },
    "/api/poistopalvelu/resume/pdf": {
      "get": {
        "summary": "The saved offer's agreement PDF",
        "operationId": "getPoistoResumePdf",
        "tags": [
          "poistopalvelu"
        ],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Resume token from the \"Ota tarjous talteen\" email."
          }
        ],
        "responses": {
          "200": {
            "description": "Agreement PDF.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Unknown or expired token."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/resume/complete": {
      "post": {
        "summary": "Confirm a saved offer",
        "description": "Accepts the agreement at the frozen price; the stored routing decision picks confirmed or pending_review.",
        "operationId": "completePoistoResume",
        "tags": [
          "poistopalvelu"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string"
                  }
                },
                "required": [
                  "token"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Completed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "booking_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Unknown or expired token."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/poistopalvelu/call-requested": {
      "post": {
        "summary": "Ask to be called about an item removal",
        "description": "Creates a pending_review request that an operator calls about; holds the slot when a timeslot is given and still free for a job that size (slot_held says whether it did; a taken window or a job too long for online booking is accepted without a slot, and the time is agreed on the phone). service_date is required, timeslot and consents are not, and it is accepted even when the description names restricted items or the pickup route cannot be resolved.",
        "operationId": "requestPoistoCall",
        "tags": [
          "poistopalvelu"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/PoistoCallRequest"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "agreement_idempotency_key": {
                        "type": "string",
                        "description": "X-Idempotency-Key from /api/poistopalvelu/agreement/preview."
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Request recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "booking_id": {
                      "type": "string"
                    },
                    "slot_held": {
                      "type": "boolean",
                      "description": "true when the chosen arrival window is held for this request; false when no window was given, it was taken, or the job is too long to hold one online — the time is then agreed on the phone."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "agreement_idempotency_key is unknown for this customer email."
          },
          "409": {
            "description": "The agreement behind agreement_idempotency_key was already booked (detail string)."
          },
          "503": {
            "description": "The pricing engine failed; detail is a Finnish message. Try again shortly. (An unresolvable pickup route is not an error here.)"
          },
          "422": {
            "$ref": "#/components/responses/PoistoCallUnprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/garbage/cancel": {
      "post": {
        "summary": "Cancel an item-removal (poistopalvelu) booking",
        "description": "As /api/cancel, for the waste-removal vertical. Rate limit: 10 requests/hour.",
        "operationId": "cancelGarbageBooking",
        "tags": ["booking"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "token": { "type": "string" } },
                "required": ["token"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Booking cancelled." },
          "404": { "description": "Unknown or already-used token." }
        }
      }
    }
  },
  "components": {
    "responses": {
      "RateLimited": { "description": "Rate limit exceeded." },
      "RouteUnavailable": {
        "description": "The pickup address could not be routed, so the driving cost cannot be priced: detail {code: route_unavailable, message}. Check the address or try again; /call-requested still accepts the draft.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "detail": { "type": "object", "properties": { "code": { "type": "string", "enum": ["route_unavailable"] }, "message": { "type": "string" } } }
              }
            }
          }
        }
      },
      "PoistoUnprocessable": {
        "description": "One of: code restricted_items — the description names items that cannot be collected online (a call request is still accepted); photos_unavailable — a photos-only draft whose upload session is claimed, expired or unknown: add the photos again (new session) or describe the items; or FastAPI validation errors (detail list).",
        "content": {
          "application/json": {
            "schema": {
              "oneOf": [
                { "$ref": "#/components/schemas/PoistoRestrictedItemsError" },
                { "$ref": "#/components/schemas/PoistoPhotosUnavailableError" },
                { "$ref": "#/components/schemas/PoistoValidationError" }
              ]
            }
          }
        }
      },
      "PoistoCallUnprocessable": {
        "description": "photos_unavailable (see PoistoUnprocessable) or FastAPI validation errors. Restricted items are not an error here.",
        "content": {
          "application/json": {
            "schema": {
              "oneOf": [
                { "$ref": "#/components/schemas/PoistoPhotosUnavailableError" },
                { "$ref": "#/components/schemas/PoistoValidationError" }
              ]
            }
          }
        }
      },
      "ValidationError": {
        "description": "Request failed validation. `detail` is either a string or a list of per-field error objects.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "detail": {
                  "oneOf": [
                    { "type": "string" },
                    { "type": "array", "items": { "type": "object", "properties": { "loc": { "type": "array", "items": {} }, "msg": { "type": "string" }, "type": { "type": "string" } } } }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "PoistoConflictError": {
        "type": "object",
        "required": ["detail"],
        "properties": {
          "detail": {
            "oneOf": [
              {
                "type": "object",
                "required": ["code"],
                "properties": {
                  "code": { "type": "string", "enum": ["slot_taken", "job_too_long", "agreement_mismatch"] },
                  "message": { "type": "string", "description": "Finnish, for display." }
                }
              },
              { "type": "string", "description": "Conflicts without a code, e.g. \"This agreement has already been booked.\" or save-for-later's \"liian lähellä\"." }
            ]
          }
        }
      },
      "PoistoRestrictedItemsError": {
        "type": "object",
        "required": ["code", "items"],
        "properties": {
          "code": { "type": "string", "enum": ["restricted_items"] },
          "items": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string" }, "label_fi": { "type": "string" } } } }
        }
      },
      "PoistoPhotosUnavailableError": {
        "type": "object",
        "description": "Sent as detail {code, message}; clients should also accept a top-level code.",
        "properties": {
          "detail": {
            "type": "object",
            "required": ["code"],
            "properties": {
              "code": { "type": "string", "enum": ["photos_unavailable"] },
              "message": { "type": "string" }
            }
          },
          "code": { "type": "string", "enum": ["photos_unavailable"] }
        }
      },
      "PoistoValidationError": {
        "type": "object",
        "properties": {
          "detail": { "type": "array", "items": { "type": "object", "properties": { "loc": { "type": "array", "items": {} }, "msg": { "type": "string" }, "type": { "type": "string" } } } }
        }
      },
      "Timeslot": {
        "type": "string",
        "enum": ["08-09", "12-13", "16-17", "20-21"],
        "description": "Arrival window, Europe/Helsinki. This is when the crew arrives, not the length of the job."
      },
      "ItemDensity": {
        "type": "string",
        "enum": ["minimalist", "standard", "large", "well equipped"],
        "description": "How much there is to move, relative to the size of the home."
      },
      "PoistoSizeBucket": {
        "type": "string",
        "enum": [
          "small",
          "medium",
          "large",
          "extra_large"
        ],
        "description": "small = pieni huone / leikkimökki, medium = huone / varasto, large = autotalli tms., extra_large = asunnon tyhjennys (needs square_meters and rooms; priced as its base plus a price for the home's size)."
      },
      "PoistoPickup": {
        "type": "object",
        "required": [
          "street",
          "postal_code",
          "city"
        ],
        "properties": {
          "street": {
            "type": "string"
          },
          "postal_code": {
            "type": "string",
            "pattern": "^\\d{5}$"
          },
          "city": {
            "type": "string"
          },
          "lat": {
            "type": "number"
          },
          "lon": {
            "type": "number"
          }
        }
      },
      "PoistoQuoteRequest": {
        "type": "object",
        "required": [
          "size_bucket"
        ],
        "properties": {
          "size_bucket": {
            "$ref": "#/components/schemas/PoistoSizeBucket"
          },
          "square_meters": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "extra_large only; required there, rejected otherwise."
          },
          "rooms": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50,
            "description": "extra_large only; required there, rejected otherwise."
          },
          "pickup": {
            "$ref": "#/components/schemas/PoistoPickup"
          },
          "items_description": {
            "type": "string",
            "maxLength": 5000
          },
          "timeslot": {
            "$ref": "#/components/schemas/Timeslot"
          },
          "service_date": {
            "type": "string",
            "format": "date",
            "description": "The chosen pickup date, used only to decide Yhteiskuljetusetu. Unlike a booking's service_date it is not checked against the today+2 … today+90 window: a date outside it simply earns no discount."
          },
          "ux_session_id": {
            "type": "string"
          }
        }
      },
      "PoistoDraft": {
        "type": "object",
        "description": "The full draft. timeslot is required except on /call-requested (where the slot is held only when one is given). consents must both be true on /booking and /save-for-later, and are not required on /agreement/preview or /call-requested. Full-draft routes need items_description or image_upload_session.",
        "required": [
          "size_bucket",
          "pickup",
          "service_date",
          "timeslot",
          "customer"
        ],
        "properties": {
          "size_bucket": {
            "$ref": "#/components/schemas/PoistoSizeBucket"
          },
          "square_meters": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 1000
          },
          "rooms": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 50
          },
          "items_description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 5000
          },
          "image_upload_session": {
            "type": [
              "string",
              "null"
            ],
            "description": "From POST /api/booking/images/session."
          },
          "pickup": {
            "$ref": "#/components/schemas/PoistoPickup"
          },
          "service_date": {
            "type": "string",
            "format": "date",
            "description": "Two to 90 days ahead, Europe/Helsinki."
          },
          "timeslot": {
            "$ref": "#/components/schemas/Timeslot"
          },
          "customer_type": {
            "type": "string",
            "enum": [
              "household",
              "business"
            ]
          },
          "customer": {
            "type": "object",
            "required": [
              "name",
              "phone",
              "email"
            ],
            "properties": {
              "name": {
                "type": "string"
              },
              "phone": {
                "type": "string",
                "description": "Finnish number; normalised server-side."
              },
              "email": {
                "type": "string",
                "format": "email"
              }
            }
          },
          "consents": {
            "type": "object",
            "properties": {
              "privacy": {
                "type": "boolean"
              },
              "terms": {
                "type": "boolean"
              }
            }
          },
          "ux_session_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "gclid": {
            "type": [
              "string",
              "null"
            ]
          },
          "fbclid": {
            "type": [
              "string",
              "null"
            ]
          },
          "fbc": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "PoistoCallRequest": {
        "type": "object",
        "description": "The draft /call-requested takes: PoistoDraft with timeslot optional (models/poisto_requests.py PoistoCallRequest) and consents not required.",
        "required": [
          "size_bucket",
          "pickup",
          "service_date",
          "customer"
        ],
        "properties": {
          "size_bucket": {
            "$ref": "#/components/schemas/PoistoDraft/properties/size_bucket"
          },
          "square_meters": {
            "$ref": "#/components/schemas/PoistoDraft/properties/square_meters"
          },
          "rooms": {
            "$ref": "#/components/schemas/PoistoDraft/properties/rooms"
          },
          "items_description": {
            "$ref": "#/components/schemas/PoistoDraft/properties/items_description"
          },
          "image_upload_session": {
            "$ref": "#/components/schemas/PoistoDraft/properties/image_upload_session"
          },
          "pickup": {
            "$ref": "#/components/schemas/PoistoDraft/properties/pickup"
          },
          "service_date": {
            "$ref": "#/components/schemas/PoistoDraft/properties/service_date"
          },
          "customer_type": {
            "$ref": "#/components/schemas/PoistoDraft/properties/customer_type"
          },
          "customer": {
            "$ref": "#/components/schemas/PoistoDraft/properties/customer"
          },
          "consents": {
            "$ref": "#/components/schemas/PoistoDraft/properties/consents"
          },
          "ux_session_id": {
            "$ref": "#/components/schemas/PoistoDraft/properties/ux_session_id"
          },
          "gclid": {
            "$ref": "#/components/schemas/PoistoDraft/properties/gclid"
          },
          "fbclid": {
            "$ref": "#/components/schemas/PoistoDraft/properties/fbclid"
          },
          "fbc": {
            "$ref": "#/components/schemas/PoistoDraft/properties/fbc"
          },
          "timeslot": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Timeslot"
              },
              {
                "type": "null"
              }
            ]
          },
          "agreement_idempotency_key": {
            "type": "string",
            "description": "X-Idempotency-Key from /api/poistopalvelu/agreement/preview."
          }
        }
      },
      "PoistoQuote": {
        "type": "object",
        "properties": {
          "price": {
            "type": "object",
            "description": "EUR, VAT included everywhere; vat_included_eur is the share of total_eur, not an addition.",
            "properties": {
              "base_eur": {
                "type": "number"
              },
              "driving_eur": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "null until a pickup is given."
              },
              "size_extra_eur": {
                "type": "number"
              },
              "discount_eur": {
                "type": "number",
                "description": "Yhteiskuljetusetu: a POSITIVE amount already subtracted from total_eur, 0 when the date does not qualify. The server decides it from service_date against its own bookings; a client-sent discount is ignored."
              },
              "shared_transport": {
                "type": "boolean",
                "description": "Whether that benefit was applied to this quote."
              },
              "total_eur": {
                "type": "number"
              },
              "vat_rate_pct": {
                "type": "number"
              },
              "vat_included_eur": {
                "type": "number"
              }
            }
          },
          "duration_h": {
            "type": "number",
            "description": "For scheduling only; never a price."
          },
          "estimated_trips": {
            "type": "integer"
          },
          "route": {
            "type": "object",
            "properties": {
              "main_km": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "total_km": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "resolved": {
                "type": "boolean"
              },
              "pickup": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The point the pickup was routed from: the request's pickup.lat/lon, or the server's own geocode of the address when they were absent (geocoded: true). Null when the pickup could not be placed.",
                "properties": {
                  "lat": {
                    "type": "number"
                  },
                  "lon": {
                    "type": "number"
                  },
                  "geocoded": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "lat",
                  "lon",
                  "geocoded"
                ]
              }
            }
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "key": {
                  "type": "string"
                },
                "label_fi": {
                  "type": "string"
                },
                "decision": {
                  "type": "string",
                  "enum": [
                    "ok",
                    "review",
                    "blocked"
                  ]
                }
              }
            }
          },
          "restricted": {
            "type": "boolean",
            "description": "true when any item is blocked: booking, save-for-later and the agreement then return 422 restricted_items."
          },
          "nlp_available": {
            "type": "boolean"
          }
        }
      },
      "PoistoPricing": {
        "type": "object",
        "properties": {
          "vat_rate_pct": {
            "type": "number"
          },
          "shared_transport_discount_eur": {
            "type": "number",
            "description": "Yhteiskuljetusetu: the euros off a removal booked onto a date that already carries another removal collection. VAT included. Read it from here rather than hard-coding 5."
          },
          "shared_transport_label_fi": {
            "type": "string",
            "description": "That benefit's customer-facing name, e.g. \"Yhteiskuljetusetu\"."
          },
          "buckets": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "key": {
                  "$ref": "#/components/schemas/PoistoSizeBucket"
                },
                "label_fi": {
                  "type": "string"
                },
                "desc_fi": {
                  "type": "string"
                },
                "base_eur": {
                  "type": "number",
                  "description": "VAT included; a minimum. The page's size cards no longer print it — it is the base row of the price breakdown."
                },
                "requires_size_details": {
                  "type": "boolean"
                }
              }
            }
          }
        }
      },
      "ElevatorSize": { "type": "string", "enum": ["pieni", "keskikokoinen", "iso"] },
      "PriceEstimateRequest": {
        "type": "object",
        "description": "All fields are optional and default to 0/false, so a partially filled form can be priced. The estimate improves as more is supplied.",
        "properties": {
          "rooms": { "type": "integer", "minimum": 0, "maximum": 20 },
          "square_meters": { "type": "integer", "minimum": 0, "maximum": 500 },
          "item_density": { "$ref": "#/components/schemas/ItemDensity" },
          "trip_length_km_1way": { "type": "number", "minimum": 0 },
          "from_street": { "type": "string", "maxLength": 200 },
          "from_city": { "type": "string", "maxLength": 100 },
          "from_postal_code": { "type": "string", "maxLength": 10 },
          "to_street": { "type": "string", "maxLength": 200 },
          "to_city": { "type": "string", "maxLength": 100 },
          "to_postal_code": { "type": "string", "maxLength": 10 },
          "from_lat": { "type": "number" },
          "from_lon": { "type": "number" },
          "to_lat": { "type": "number" },
          "to_lon": { "type": "number" },
          "from_floor": { "type": "integer", "minimum": 0, "maximum": 100 },
          "to_floor": { "type": "integer", "minimum": 0, "maximum": 100 },
          "from_elevator_available": { "type": "boolean" },
          "to_elevator_available": { "type": "boolean" },
          "from_elevator_size": { "$ref": "#/components/schemas/ElevatorSize" },
          "to_elevator_size": { "$ref": "#/components/schemas/ElevatorSize" },
          "narrow_stairs": { "type": "boolean" },
          "carrying_distance_m": { "type": "integer", "minimum": 0, "maximum": 500 },
          "items_need_packing": { "type": "boolean" },
          "is_student": { "type": "boolean", "description": "Applies the student discount." },
          "vehicle_type": { "type": "string", "enum": ["van", "van_trailer"], "description": "Normally chosen by Muutto365 rather than the customer." }
        }
      },
      "PriceEstimate": {
        "type": "object",
        "properties": {
          "price_itemized": {
            "type": "object",
            "description": "EUR. final_price is the customer-facing total, VAT included.",
            "properties": {
              "base": { "type": "number" },
              "drive": { "type": "number" },
              "volume": { "type": "number" },
              "carry": { "type": "number" },
              "packing": { "type": "number" },
              "elevator": { "type": "number" },
              "labor_cost": { "type": "number" },
              "subtotal": { "type": "number" },
              "discount_abs": { "type": "number" },
              "alv_abs": { "type": "number", "description": "VAT amount included in final_price." },
              "final_price": { "type": "number" }
            }
          },
          "time_itemized": {
            "type": "object",
            "properties": { "total": { "type": "number", "description": "Estimated crew hours. Feed this to /api/timeslots." } }
          },
          "resolved_distance_km": {
            "type": ["number", "null"],
            "description": "Real routed one-way distance when both addresses resolved; null when the route could not be resolved."
          },
          "resolved_duration_min": {
            "type": ["number", "null"],
            "description": "One-way drive time in minutes for the same route; null exactly when resolved_distance_km is."
          }
        }
      },
      "BookingRequest": {
        "type": "object",
        "required": [
          "customer_name", "email", "phone",
          "from_address", "from_city", "from_postal_code",
          "to_address", "to_city", "to_postal_code",
          "move_date", "timeslot", "privacy_policy_accepted", "terms_accepted"
        ],
        "properties": {
          "customer_name": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "phone": { "type": "string", "description": "Finnish number." },
          "from_address": { "type": "string" },
          "from_city": { "type": "string" },
          "from_postal_code": { "type": "string" },
          "to_address": { "type": "string" },
          "to_city": { "type": "string" },
          "to_postal_code": { "type": "string" },
          "move_date": { "type": "string", "format": "date", "description": "Today or at most 90 days ahead." },
          "timeslot": { "$ref": "#/components/schemas/Timeslot" },
          "trip_length_km_1way": { "type": "number" },
          "rooms": { "type": "integer" },
          "square_meters": { "type": "integer" },
          "item_density": { "$ref": "#/components/schemas/ItemDensity" },
          "from_floor": { "type": ["integer", "null"] },
          "to_floor": { "type": ["integer", "null"] },
          "from_elevator_available": { "type": "boolean" },
          "to_elevator_available": { "type": "boolean" },
          "from_elevator_size": { "$ref": "#/components/schemas/ElevatorSize" },
          "to_elevator_size": { "$ref": "#/components/schemas/ElevatorSize" },
          "narrow_stairs": { "type": "boolean" },
          "carrying_distance_m": { "type": "integer" },
          "items_need_packing": { "type": "boolean" },
          "is_student": { "type": "boolean" },
          "move_description": { "type": "string", "maxLength": 3000, "description": "General inventory of what is being moved." },
          "heavy_items_description": { "type": "string", "maxLength": 2000, "description": "Explicit heavy or special-handling items. Check /api/nlp for blocking items first." },
          "extra_notes": { "type": "string" },
          "privacy_policy_accepted": { "type": "boolean" },
          "terms_accepted": { "type": "boolean" },
          "agreement_idempotency_key": { "type": "string", "description": "From the accepted agreement." }
        }
      }
    }
  }
}
