{"openapi":"3.1.0","info":{"title":"Hearth & Tap Booking API","version":"1.0.0","description":"Demo business for an Agentplace test task. The same use cases are exposed as MCP tools at /mcp."},"components":{"schemas":{"Health":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]}},"required":["status"]},"ServiceList":{"type":"object","properties":{"services":{"type":"array","items":{"type":"object","properties":{"jobType":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"]},"label":{"type":"string"},"gasSafeRequired":{"type":"boolean","description":"True when only a Gas Safe registered engineer can do the job"},"emergencyEligible":{"type":"boolean","description":"True when urgency = emergency is allowed for this job"},"pricing":{"$ref":"#/components/schemas/JobPricing"},"pricingBasis":{"type":"string","description":"Customer-ready pricing text with VAT-inclusive prices; quote these to customers, not the raw ex-VAT pricing numbers"}},"required":["jobType","label","gasSafeRequired","emergencyEligible","pricing","pricingBasis"]}},"safetyNotice":{"type":"string","description":"Show this to the customer if they mention gas or carbon monoxide. Never book those."},"pricingNote":{"type":"string","description":"Applies to every service: show it once alongside the price list"}},"required":["services","safetyNotice","pricingNote"]},"JobPricing":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["hourly"]},"minHours":{"type":"number"},"maxHours":{"type":"number"},"partsMinPence":{"type":"integer"},"partsMaxPence":{"type":"integer"}},"required":["kind","minHours","maxHours","partsMinPence","partsMaxPence"]},{"type":"object","properties":{"kind":{"type":"string","enum":["fixed"]},"fixedPence":{"type":"integer"}},"required":["kind","fixedPence"]}]},"ServiceArea":{"type":"object","properties":{"postcode":{"type":"string","example":"SW4 7AA","description":"Normalised postcode"},"covered":{"type":"boolean","description":"Whether we can send an engineer to this postcode"},"borough":{"type":["string","null"],"example":"Lambeth"},"distanceMiles":{"type":"number","description":"Straight-line distance from our Clapham base, 1 decimal place"},"travelFeePence":{"type":["integer","null"],"description":"Travel fee in pence ex VAT; null when not covered"},"reason":{"type":["string","null"],"enum":["outside_london","too_far",null],"description":"Why the postcode is not covered; null when covered"}},"required":["postcode","covered","borough","distanceMiles","travelFeePence","reason"]},"Problem":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string","example":"INVALID_POSTCODE"},"detail":{"type":"string"}},"required":["type","title","status","code","detail"]},"QuoteResult":{"allOf":[{"$ref":"#/components/schemas/Quote"},{"type":"object","properties":{"jobType":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"]},"jobLabel":{"type":"string"},"urgency":{"type":"string","enum":["standard","emergency"]},"postcode":{"type":"string"},"windowStart":{"type":["string","null"],"description":"The window the price is for; null when no window was given"},"tierNote":{"type":"string","description":"Says which tier the price used and why. Tell the customer when it is only an estimate."}},"required":["jobType","jobLabel","urgency","postcode","windowStart","tierNote"]}]},"Quote":{"type":"object","properties":{"tier":{"type":"string","enum":["standard","saturday","out_of_hours"],"description":"standard = weekday daytime, saturday = x1.25 labour, out_of_hours = x1.5 labour"},"lineItems":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string"},"lowPence":{"type":"integer","description":"Pence ex VAT"},"highPence":{"type":"integer","description":"Pence ex VAT"}},"required":["description","lowPence","highPence"]}},"subtotalLowPence":{"type":"integer","description":"Pence ex VAT"},"subtotalHighPence":{"type":"integer","description":"Pence ex VAT"},"vatLowPence":{"type":"integer"},"vatHighPence":{"type":"integer"},"totalLowPence":{"type":"integer","description":"Pence including 20% VAT. Divide by 100 for pounds."},"totalHighPence":{"type":"integer","description":"Pence including 20% VAT. Divide by 100 for pounds."},"currency":{"type":"string","enum":["GBP"]},"note":{"type":"string"}},"required":["tier","lineItems","subtotalLowPence","subtotalHighPence","vatLowPence","vatHighPence","totalLowPence","totalHighPence","currency","note"]},"QuoteRequest":{"type":"object","properties":{"jobType":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"],"example":"dripping_tap","description":"Which job the customer needs. Use list_services to see the options and map the customer's description to one of them."},"postcode":{"type":"string","minLength":1,"maxLength":16,"example":"SW4 7AA","description":"Full UK postcode, any casing or spacing"},"urgency":{"type":"string","enum":["standard","emergency"],"default":"standard","example":"standard","description":"standard (default) or emergency. Emergency is only allowed for jobs with emergencyEligible = true and adds out-of-hours windows and a GBP 40 surcharge."},"windowStart":{"type":"string","format":"date-time","example":"2026-11-09T08:00:00+00:00","description":"The start of an arrival window, copied exactly from the start field returned by find_availability."}},"required":["jobType","postcode"]},"Availability":{"type":"object","properties":{"jobType":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"]},"urgency":{"type":"string","enum":["standard","emergency"]},"from":{"type":"string"},"days":{"type":"integer"},"windows":{"type":"array","items":{"$ref":"#/components/schemas/Window"},"description":"Open 2-hour arrival windows in time order. Empty means nothing free: offer another range."}},"required":["jobType","urgency","from","days","windows"]},"Window":{"type":"object","properties":{"start":{"type":"string","example":"2026-11-09T08:00:00+00:00","description":"Window start, ISO 8601 with London offset. Pass this exactly as windowStart."},"end":{"type":"string"},"label":{"type":"string","example":"Mon 9 Nov, 8–10am","description":"Show this to the customer"},"tier":{"type":"string","enum":["standard","saturday","out_of_hours"]}},"required":["start","end","label","tier"]},"Booking":{"type":"object","properties":{"reference":{"type":"string","example":"HT-7K3M9Q","description":"Give this to the customer"},"status":{"type":"string","enum":["confirmed","cancelled"]},"jobType":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"]},"jobLabel":{"type":"string"},"urgency":{"type":"string","enum":["standard","emergency"]},"window":{"$ref":"#/components/schemas/Window"},"engineer":{"type":"object","properties":{"firstName":{"type":"string"}},"required":["firstName"]},"postcode":{"type":"string"},"addressLine1":{"type":"string"},"customerName":{"type":"string"},"quote":{"$ref":"#/components/schemas/Quote"},"cancellableUntil":{"type":"string","description":"The customer can cancel free of charge until this time (2 hours before the window). Still present on cancelled or past bookings; check status first."}},"required":["reference","status","jobType","jobLabel","urgency","window","engineer","postcode","addressLine1","customerName","quote","cancellableUntil"]},"BookVisitRequest":{"type":"object","properties":{"jobType":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"],"example":"dripping_tap","description":"Which job the customer needs. Use list_services to see the options and map the customer's description to one of them."},"urgency":{"type":"string","enum":["standard","emergency"],"default":"standard","example":"standard","description":"standard (default) or emergency. Emergency is only allowed for jobs with emergencyEligible = true and adds out-of-hours windows and a GBP 40 surcharge."},"postcode":{"type":"string","minLength":1,"maxLength":16,"example":"SW4 7AA","description":"Full UK postcode, any casing or spacing"},"addressLine1":{"type":"string","minLength":1,"maxLength":120,"example":"14 Acre Lane","description":"First line of the address"},"windowStart":{"type":"string","format":"date-time","example":"2026-11-09T08:00:00+00:00","description":"The start of an arrival window, copied exactly from the start field returned by find_availability."},"customer":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"example":"Sam Carter","description":"Customer full name"},"phone":{"type":"string","minLength":1,"maxLength":30,"example":"07700 900123","description":"UK phone number: 07... or +44..."},"email":{"type":"string","maxLength":120,"format":"email","description":"Optional email address"}},"required":["name","phone"],"description":"Who we are visiting"},"problemDescription":{"type":"string","minLength":1,"maxLength":1000,"description":"What is wrong, in the customer's words"}},"required":["jobType","postcode","addressLine1","windowStart","customer","problemDescription"]},"CancelRequest":{"type":"object","properties":{"postcode":{"type":"string","minLength":1,"maxLength":16,"example":"SW4 7AA","description":"Full UK postcode, any casing or spacing"}},"required":["postcode"]}},"parameters":{}},"paths":{"/health":{"get":{"tags":["Operations"],"summary":"Liveness check","responses":{"200":{"description":"The service is up","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/api/v1/services":{"get":{"tags":["Catalogue"],"summary":"List the jobs we do and how they are priced","description":"The 9 job types with pricing basis, Gas Safe requirement and emergency eligibility, plus the gas-emergency safety notice.","responses":{"200":{"description":"The catalogue","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceList"}}}}}}},"/api/v1/service-area":{"get":{"tags":["Service area"],"summary":"Check whether we cover a postcode","description":"Looks the postcode up on postcodes.io and returns borough, distance from our Clapham base and the travel fee.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":16,"example":"SW4 7AA","description":"Full UK postcode, any casing or spacing"},"required":true,"description":"Full UK postcode, any casing or spacing","name":"postcode","in":"query"}],"responses":{"200":{"description":"Coverage result (covered may be false)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceArea"}}}},"400":{"description":"Missing postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Not a valid UK postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"Unexpected server error (INTERNAL_ERROR)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"postcodes.io unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/quotes":{"post":{"tags":["Quotes"],"summary":"Price range for a job at a postcode","description":"Prices a job as a low-high range in pence including VAT. Without windowStart it uses the standard tier and says so; with windowStart it prices that exact window.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteRequest"}}}},"responses":{"200":{"description":"The quote","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResult"}}}},"400":{"description":"Invalid request body","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Invalid postcode, postcode outside our area, emergency on an ineligible job, or windowStart is not a real window","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"Unexpected server error (INTERNAL_ERROR)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"postcodes.io or the bank-holiday calendar is unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/availability":{"get":{"tags":["Availability"],"summary":"Open 2-hour arrival windows","description":"Windows in the next 1-14 days that have at least one free eligible engineer, with the price tier of each.","parameters":[{"schema":{"type":"string","enum":["dripping_tap","blocked_sink","toilet_repair","leak_investigation","burst_pipe","radiator_problem","no_heating_hot_water","boiler_service","gas_safety_certificate"],"example":"dripping_tap","description":"Which job the customer needs. Use list_services to see the options and map the customer's description to one of them."},"required":true,"description":"Which job the customer needs. Use list_services to see the options and map the customer's description to one of them.","name":"jobType","in":"query"},{"schema":{"type":"string","enum":["standard","emergency"],"default":"standard","example":"standard","description":"standard (default) or emergency. Emergency is only allowed for jobs with emergencyEligible = true and adds out-of-hours windows and a GBP 40 surcharge."},"required":false,"description":"standard (default) or emergency. Emergency is only allowed for jobs with emergencyEligible = true and adds out-of-hours windows and a GBP 40 surcharge.","name":"urgency","in":"query"},{"schema":{"type":"string","format":"date","example":"2026-11-09","description":"First day to search, YYYY-MM-DD (London). Defaults to today."},"required":false,"description":"First day to search, YYYY-MM-DD (London). Defaults to today.","name":"from","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":14,"default":7,"example":7,"description":"How many days to search, 1-14. Defaults to 7."},"required":false,"description":"How many days to search, 1-14. Defaults to 7.","name":"days","in":"query"}],"responses":{"200":{"description":"Open windows (may be empty)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Availability"}}}},"400":{"description":"Invalid job type, urgency, from date or days (1-14)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Emergency requested for a job that is not emergency eligible","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"Unexpected server error (INTERNAL_ERROR)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"The bank-holiday calendar is unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/bookings":{"post":{"tags":["Bookings"],"summary":"Book a visit","description":"Re-validates the area, window and notice, prices the visit, allocates a free eligible engineer and returns the booking with its reference. Send a unique Idempotency-Key: repeating a key returns the original booking (200) instead of creating another.","parameters":[{"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{8,100}$","example":"booking-2026-11-04-abc123","description":"A unique id you generate once per booking attempt (8-100 characters: letters, digits, - or _). Re-send the SAME key if you retry after an error or timeout, so the customer is never booked twice."},"required":true,"description":"A unique id you generate once per booking attempt (8-100 characters: letters, digits, - or _). Re-send the SAME key if you retry after an error or timeout, so the customer is never booked twice.","name":"idempotency-key","in":"header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookVisitRequest"}}}},"responses":{"200":{"description":"This Idempotency-Key was already used: the original booking","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"201":{"description":"Booking confirmed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"400":{"description":"Invalid body, phone number or Idempotency-Key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"SLOT_UNAVAILABLE: every eligible engineer is booked for that window","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Invalid or uncovered postcode, invalid window, or emergency on an ineligible job","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"Unexpected server error (INTERNAL_ERROR)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"postcodes.io or the bank-holiday calendar is unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/bookings/{reference}":{"get":{"tags":["Bookings"],"summary":"Booking status","description":"Needs the reference and the postcode it was booked for. A wrong postcode returns the same 404 as an unknown reference.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20,"example":"HT-7K3M9Q","description":"Booking reference, like HT-7K3M9Q"},"required":true,"description":"Booking reference, like HT-7K3M9Q","name":"reference","in":"path"},{"schema":{"type":"string","minLength":1,"maxLength":16,"example":"SW4 7AA","description":"Full UK postcode, any casing or spacing"},"required":true,"description":"Full UK postcode, any casing or spacing","name":"postcode","in":"query"}],"responses":{"200":{"description":"The booking","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"400":{"description":"Missing postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"No booking with that reference and postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Not a valid UK postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"Unexpected server error (INTERNAL_ERROR)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/bookings/{reference}/cancel":{"post":{"tags":["Bookings"],"summary":"Cancel a booking","description":"Allowed until 2 hours before the arrival window starts (inclusive). Cancelling an already-cancelled booking returns it unchanged with 200.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20,"example":"HT-7K3M9Q","description":"Booking reference, like HT-7K3M9Q"},"required":true,"description":"Booking reference, like HT-7K3M9Q","name":"reference","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CancelRequest"}}}},"responses":{"200":{"description":"The cancelled booking","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"400":{"description":"Missing postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"No booking with that reference and postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"TOO_LATE_TO_CANCEL: less than 2 hours before the window","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Not a valid UK postcode","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"500":{"description":"Unexpected server error (INTERNAL_ERROR)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}}},"webhooks":{}}