{
  "openapi": "3.1.0",
  "info": {
    "title": "NogPets API",
    "version": "1.0.0",
    "summary": "Book mobile dog grooming, walks and pet sitting in Stellenbosch, South Africa.",
    "description": "NogPets is the service itself: our vans and our walkers, in Stellenbosch (South Africa).\nThis API is what the NogPets app and WhatsApp use, for assistants and developers:\ncheck coverage, prices and open times without signing in; with the person's\npermission, book, move, cancel and pay, and buy from the shop when it is open.\n\n- **The person approves money.** A card booking or order comes back with a `payUrl`\n  the person opens to pay on Payfast. Nothing here takes a card. A cash booking needs\n  `confirm: true`, sent only after the person has seen the summary and said yes.\n- **Same rules as the app.** Prices, coverage, open times and cancellation windows\n  come from the same server code; the API adds none of its own.\n- Money is integer cents in South African rand (ZAR): `38000` is R380. Times are ISO\n  8601 instants; local time is Africa/Johannesburg (UTC+2, no daylight saving).\n- Errors are `{\"error\": {\"code\", \"message\", \"details\"}}`; `code` is stable\n  (e.g. `NO_COVERAGE`, `SLOT_TAKEN`, `CONFIRM_REQUIRED`).\n\nReads marked public need no auth. Everything about a person needs an OAuth 2.1\naccess token (authorization code + PKCE) or a personal API key, as a Bearer token.\nThe same operations are MCP tools at `https://mcp.nogpets.com/mcp`.\n\n**Pet businesses can join through an assistant** (tag Business, scope\n`business:write`): a groomer, walker or sitter registers their business in eight\ncalls, in this order: startBusinessSignup → addBusinessServices → setServiceArea →\naddResources → inviteStaff (optional) → connectPayfast (optional, merchant id only) →\ngetSignupStatus → submitBusinessForReview (`confirm: true` after the person saw the\nsummary). Everything stays in a draft application until NogPets approves it; every\nanswer carries `next` and a `statusUrl` page for the person.\n",
    "contact": {
      "name": "NogPets",
      "email": "hello@nogpets.com",
      "url": "https://nogpets.com/developers"
    },
    "termsOfService": "https://nogpets.com/terms"
  },
  "servers": [
    {
      "url": "https://api.nogpets.com",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Services",
      "description": "What we do, where, and for how much. Public."
    },
    {
      "name": "Booking",
      "description": "Open times, booking, moving, cancelling and paying."
    },
    {
      "name": "Account",
      "description": "The person's profile, pets and addresses."
    },
    {
      "name": "Shop",
      "description": "Pet food, toys, beds and accessories. Only while the shop is open (else `SHOP_OFF`)."
    },
    {
      "name": "Directory",
      "description": "Pet businesses in and around Stellenbosch. Public."
    },
    {
      "name": "Business",
      "description": "For pet businesses: register a grooming, walking or sitting business on NogPets through an assistant. Scope business:write; nothing is live until NogPets approves it."
    }
  ],
  "security": [],
  "paths": {
    "/v1/services": {
      "get": {
        "operationId": "listServices",
        "tags": [
          "Services"
        ],
        "summary": "Service lines, services and prices",
        "description": "Every service line (grooming, walks, pet sitting), its services with prices by pet size and coat, the card fee, and the areas we cover.",
        "x-mcp-tool": {
          "name": "list_services",
          "title": "What NogPets offers",
          "description": "Lists NogPets' services (mobile dog grooming at the door, dog walks, pet sitting) with prices and durations, the R10 card fee, whether cash is taken, and the Stellenbosch suburbs we cover.\nUse it first when the person asks what NogPets does, how much something costs, or which service to pick. Prices depend on the pet: `tiers` give the price by weight and coat (first match wins; minKg inclusive, maxKg exclusive). For an exact price for the person's pets, call get_quote.\nService `id`s are what get_quote, find_times and book take in `serviceIds`: exactly one PRIMARY service per pet plus any EXTRAs of the same line.\n"
        },
        "responses": {
          "200": {
            "description": "The catalogue",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Services"
                },
                "example": {
                  "currency": "ZAR",
                  "serviceFeeCents": 1000,
                  "acceptsCash": true,
                  "areas": [
                    "Stellenbosch Central",
                    "Die Boord",
                    "Paradyskloof"
                  ],
                  "lines": [
                    {
                      "id": "grooming",
                      "label": "Grooming",
                      "blurb": "Our van at your door. Bath, cut and nails.",
                      "durationMode": "SUM",
                      "repeats": false,
                      "services": [
                        {
                          "id": "full-groom",
                          "name": "Full groom",
                          "kind": "PRIMARY",
                          "blurb": "Bath, cut, blow-dry, nails and ears.",
                          "petTypes": [
                            "DOG"
                          ],
                          "priceCents": 38000,
                          "durationMin": 60,
                          "fromCents": 38000,
                          "toCents": 68000,
                          "groupSize": 1,
                          "tiers": [
                            {
                              "coat": "LONG",
                              "maxKg": 10,
                              "priceCents": 46000,
                              "durationMin": 75
                            },
                            {
                              "maxKg": 10,
                              "priceCents": 38000,
                              "durationMin": 60
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/coverage": {
      "get": {
        "operationId": "checkCoverage",
        "tags": [
          "Services"
        ],
        "summary": "Do you come to this address?",
        "description": "Places a typed address, a postal code or a map point in our territories and says which service lines reach it. When nothing does, `nearest` names our closest area.",
        "x-mcp-tool": {
          "name": "check_coverage",
          "title": "Check an address is covered",
          "description": "Checks whether NogPets comes to an address, and for which services. Give `address` as the person typed it (it must name the suburb or the 4-digit postal code, e.g. \"12 Dorp Street, Stellenbosch Central 7600\"), or `lat` and `lng`, or a `postalCode`.\nUse it before quoting or booking for a new address. `covered: false` means we don't go there yet: tell the person, and name `nearest` if it helps. Never promise a visit to an address that isn't covered.\n"
        },
        "parameters": [
          {
            "name": "address",
            "in": "query",
            "description": "A street address or suburb as typed, with the suburb name or postal code; or \"lat,lng\".",
            "schema": {
              "type": "string",
              "maxLength": 300
            },
            "example": "14 Kromrivier Street, Die Boord, Stellenbosch 7613"
          },
          {
            "name": "lat",
            "in": "query",
            "schema": {
              "type": "number",
              "minimum": -90,
              "maximum": 90
            }
          },
          {
            "name": "lng",
            "in": "query",
            "schema": {
              "type": "number",
              "minimum": -180,
              "maximum": 180
            }
          },
          {
            "name": "postalCode",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]{4}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The answer (covered or not)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Coverage"
                },
                "examples": {
                  "covered": {
                    "value": {
                      "covered": true,
                      "place": {
                        "suburb": "Die Boord",
                        "postalCode": "7613",
                        "lat": -33.9467,
                        "lng": 18.848
                      },
                      "lines": [
                        {
                          "id": "grooming",
                          "label": "Grooming",
                          "covered": true
                        },
                        {
                          "id": "walking",
                          "label": "Walks",
                          "covered": true
                        },
                        {
                          "id": "sitting",
                          "label": "Pet sitting",
                          "covered": true
                        }
                      ],
                      "nearest": null
                    }
                  },
                  "notCovered": {
                    "value": {
                      "covered": false,
                      "error": "NO_COVERAGE",
                      "place": {
                        "suburb": null,
                        "postalCode": "7646",
                        "lat": -33.73,
                        "lng": 18.96
                      },
                      "lines": [
                        {
                          "id": "grooming",
                          "label": "Grooming",
                          "covered": false
                        }
                      ],
                      "nearest": {
                        "name": "Welgevonden",
                        "distanceKm": 20.4
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/quote": {
      "post": {
        "operationId": "getQuote",
        "tags": [
          "Booking"
        ],
        "summary": "Price for these pets at this address",
        "description": "The server's price and duration for one visit. Pets and address by value (no account needed), or saved ones (`addressId`, `petId`) with a token.",
        "x-mcp-tool": {
          "name": "get_quote",
          "title": "Price a visit",
          "description": "Prices one visit for the person's pets: per-pet lines, the total paying cash, the total by card (R10 card fee), and how long it takes.\nGive `category` (grooming, walking or sitting), where (`address`, or `lat`+`lng`, or `postalCode`; or a saved `addressId`), and `pets`: for each pet either a saved `petId` or its `name`, `type` (DOG/CAT), `weightKg` and `coat` (SHORT/MEDIUM/LONG), plus `serviceIds` from list_services (one PRIMARY per pet, any EXTRAs).\nNo sign-in needed for pets described by value. Quote before booking and show the person the total.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteRequest"
              },
              "example": {
                "category": "grooming",
                "address": "14 Kromrivier Street, Die Boord 7613",
                "pets": [
                  {
                    "name": "Biscuit",
                    "type": "DOG",
                    "weightKg": 8,
                    "coat": "SHORT",
                    "serviceIds": [
                      "full-groom",
                      "nails"
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The quote",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                },
                "example": {
                  "category": "grooming",
                  "durationMin": 70,
                  "pets": [
                    {
                      "name": "Biscuit",
                      "items": [
                        {
                          "serviceId": "full-groom",
                          "name": "Full groom",
                          "kind": "PRIMARY",
                          "priceCents": 38000,
                          "durationMin": 60
                        },
                        {
                          "serviceId": "nails",
                          "name": "Nails",
                          "kind": "EXTRA",
                          "priceCents": 6000,
                          "durationMin": 10
                        }
                      ]
                    }
                  ],
                  "subtotalCents": 44000,
                  "cardFeeCents": 1000,
                  "cardTotalCents": 45000,
                  "cashTotalCents": 44000,
                  "acceptsCash": true,
                  "summary": "R440 cash, or R450 by card. About 70 minutes."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/availability": {
      "post": {
        "operationId": "findTimes",
        "tags": [
          "Booking"
        ],
        "summary": "Open times",
        "description": "Open start times over a date range (at most 31 days; default the next 7), from the same engine as the app. Same inputs as the quote.",
        "x-mcp-tool": {
          "name": "find_times",
          "title": "Find open times",
          "description": "Finds open start times for a visit, day by day, with the price. Same inputs as get_quote, plus optional `from`/`to` dates (YYYY-MM-DD, local; default today and the next 6 days, at most 31 days) and a `windowStart`/`windowEnd` time of day (HH:MM, e.g. 08:00–12:00 for \"morning\").\nOvernights and several days: when the answer's `mode` is STAY (an overnight service), ask the start date, the number of nights and the evening and morning windows (`windows`) and call again with `stay`; for a service with `multiDay` true the person may want visits over several days: call again with `visits` (start date, days, visits a day, a window each). The answer's `plan` says whether it all fits and `quote` is the total. Book with the same `stay` or `visits` (no start or resourceId).\nEach slot has `start` (ISO instant) and `resourceId`: pass both unchanged to book. Offer the person a few times that fit what they asked; don't invent times. `NO_AVAILABILITY` means nothing is open in the range: try later dates.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TimesRequest"
              },
              "example": {
                "category": "grooming",
                "postalCode": "7613",
                "pets": [
                  {
                    "name": "Biscuit",
                    "type": "DOG",
                    "weightKg": 8,
                    "coat": "SHORT",
                    "serviceIds": [
                      "full-groom"
                    ]
                  }
                ],
                "from": "2026-09-29",
                "to": "2026-09-29",
                "windowStart": "08:00",
                "windowEnd": "12:00"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Days and their open slots",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Times"
                },
                "example": {
                  "durationMin": 60,
                  "quote": {
                    "category": "grooming",
                    "durationMin": 60,
                    "pets": [
                      {
                        "name": "Biscuit",
                        "items": [
                          {
                            "serviceId": "full-groom",
                            "name": "Full groom",
                            "kind": "PRIMARY",
                            "priceCents": 38000,
                            "durationMin": 60
                          }
                        ]
                      }
                    ],
                    "subtotalCents": 38000,
                    "cardFeeCents": 1000,
                    "cardTotalCents": 39000,
                    "cashTotalCents": 38000,
                    "acceptsCash": true,
                    "summary": "R380 cash, or R390 by card. About 60 minutes."
                  },
                  "days": [
                    {
                      "date": "2026-09-29",
                      "label": "Tue 29 Sep",
                      "slots": [
                        {
                          "start": "2026-09-29T06:00:00.000Z",
                          "time": "08:00",
                          "resourceId": "van-1"
                        },
                        {
                          "start": "2026-09-29T06:30:00.000Z",
                          "time": "08:30",
                          "resourceId": "van-1"
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/me": {
      "get": {
        "operationId": "myProfile",
        "tags": [
          "Account"
        ],
        "summary": "Who am I",
        "security": [
          {
            "oauth2": [
              "profile"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "profile"
        ],
        "x-mcp-tool": {
          "name": "my_profile",
          "title": "The person's profile",
          "description": "The signed-in person's name, email and phone, how many pets and addresses they have saved, and what this connection may do (scopes).\nUse it to greet them or to check who you're booking for.\n"
        },
        "responses": {
          "200": {
            "description": "The profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Profile"
                },
                "example": {
                  "uid": "u-client",
                  "name": "Sam Test",
                  "email": "client@nogpets.test",
                  "phone": "+27820000001",
                  "pets": 2,
                  "addresses": 2,
                  "client": {
                    "id": "c_1",
                    "name": "Claude"
                  },
                  "scopes": [
                    "profile",
                    "pets",
                    "addresses",
                    "bookings:read",
                    "bookings:write"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/v1/pets": {
      "get": {
        "operationId": "listPets",
        "tags": [
          "Account"
        ],
        "summary": "The person's pets",
        "security": [
          {
            "oauth2": [
              "pets"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "pets"
        ],
        "x-mcp-tool": {
          "name": "list_pets",
          "title": "The person's pets",
          "description": "The person's saved pets: id, name, type, breed, weight and coat. Use a pet's `id` as `petId` in get_quote, find_times and book.\nIf the pet they mean isn't there, ask for its type, weight and coat and add it with add_pet.\n"
        },
        "responses": {
          "200": {
            "description": "Pets",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "pets"
                  ],
                  "properties": {
                    "pets": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Pet"
                      }
                    }
                  }
                },
                "example": {
                  "pets": [
                    {
                      "id": "biscuit",
                      "name": "Biscuit",
                      "type": "DOG",
                      "breed": "Boston terrier",
                      "weightKg": 8,
                      "coat": "SHORT",
                      "birthYear": 2021,
                      "notes": "Pulls on the lead."
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "addPet",
        "tags": [
          "Account"
        ],
        "summary": "Add a pet",
        "security": [
          {
            "oauth2": [
              "pets"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "pets"
        ],
        "x-mcp-tool": {
          "name": "add_pet",
          "title": "Add a pet",
          "description": "Saves a new pet on the person's account. Needs `name`, `type` (DOG or CAT), `weightKg` (a number; an estimate is fine) and `coat` (SHORT, MEDIUM or LONG); `breed`, `birthYear` and `notes` are optional.\nAsk the person for what you don't know; don't guess the weight or coat. Returns the pet with its `id` for booking.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewPet"
              },
              "example": {
                "name": "Rex",
                "type": "DOG",
                "weightKg": 30,
                "coat": "LONG",
                "breed": "Golden retriever"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The saved pet",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pet"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/v1/addresses": {
      "get": {
        "operationId": "listAddresses",
        "tags": [
          "Account"
        ],
        "summary": "The person's addresses",
        "security": [
          {
            "oauth2": [
              "addresses"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "addresses"
        ],
        "x-mcp-tool": {
          "name": "list_addresses",
          "title": "The person's addresses",
          "description": "The person's saved addresses (label such as Home, street, suburb, notes like a gate code). Use an address's `id` as `addressId` in get_quote, find_times and book.\nIf they want a visit somewhere new, save it with add_address.\n"
        },
        "responses": {
          "200": {
            "description": "Addresses",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "addresses"
                  ],
                  "properties": {
                    "addresses": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Address"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "addAddress",
        "tags": [
          "Account"
        ],
        "summary": "Add an address",
        "security": [
          {
            "oauth2": [
              "addresses"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "addresses"
        ],
        "x-mcp-tool": {
          "name": "add_address",
          "title": "Add an address",
          "description": "Saves an address on the person's account. Needs `label` (e.g. Home) and `line1` (house number and street), and the suburb or 4-digit postal code (in `suburb`/`postalCode`, or inside `line1`); `lat`/`lng` if you have the exact point; `notes` for a gate code or where to park.\nReturns the address with its `id` and which service lines cover it. `UNKNOWN_PLACE` means the suburb or postal code wasn't recognised: ask the person for it.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewAddress"
              },
              "example": {
                "label": "Home",
                "line1": "14 Kromrivier Street",
                "suburb": "Die Boord",
                "postalCode": "7613",
                "notes": "Gate code 1234"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The saved address and its coverage",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Address"
                    },
                    {
                      "type": "object",
                      "required": [
                        "covered",
                        "lines"
                      ],
                      "properties": {
                        "covered": {
                          "type": "boolean"
                        },
                        "lines": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/LineCoverage"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        }
      }
    },
    "/v1/bookings": {
      "get": {
        "operationId": "listBookings",
        "tags": [
          "Booking"
        ],
        "summary": "The person's upcoming bookings",
        "security": [
          {
            "oauth2": [
              "bookings:read"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:read"
        ],
        "x-mcp-tool": {
          "name": "list_bookings",
          "title": "The person's bookings",
          "description": "The person's upcoming bookings (pending or accepted), soonest first: what, when, where, status, payment status and total.\nUse it when they ask what's booked, or to find the booking they want to move, cancel or pay (its `id`). `canPay: true` means a card booking still waiting for payment: get a link with pay_booking.\n"
        },
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bookings",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "bookings"
                  ],
                  "properties": {
                    "bookings": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Booking"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "book",
        "tags": [
          "Booking"
        ],
        "summary": "Book a visit",
        "description": "Books a slot from `find_times`. The server reprices and rechecks the slot. CARD →\nPENDING with a `payUrl` for the person to open (Payfast). CASH → needs `confirm: true`\n(else 409 `CONFIRM_REQUIRED` with the summary) and is PENDING with CASH_DUE.\n",
        "security": [
          {
            "oauth2": [
              "bookings:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:write"
        ],
        "x-mcp-tool": {
          "name": "book",
          "title": "Book a visit",
          "description": "Books a visit for the person. Needs `category`, a saved `addressId` (list_addresses/add_address), `pets` as [{petId, serviceIds}] with saved pet ids (list_pets/add_pet), the `start` and `resourceId` of a slot from find_times (unchanged), and `paymentMethod` CARD or CASH. Optional `notes` for the crew, and for walks `repeat` {freq: WEEKLY, days: [\"tue\"]}.\nBefore calling, show the person the service, pets, address, time and total, and get a clear yes.\nCARD: the booking is made and held, and the result has `payUrl`. Give the person that link to open and pay on Payfast themselves; you can't pay for them. It's confirmed once paid.\nCASH: send `confirm: true` only after the person said yes to the summary; without it you get CONFIRM_REQUIRED and the summary to show them. Cash is paid to the crew on the day.\nSLOT_TAKEN means someone got there first: call find_times again.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookRequest"
              },
              "example": {
                "category": "grooming",
                "addressId": "home",
                "pets": [
                  {
                    "petId": "biscuit",
                    "serviceIds": [
                      "full-groom",
                      "nails"
                    ]
                  }
                ],
                "start": "2026-09-29T07:00:00.000Z",
                "resourceId": "van-1",
                "paymentMethod": "CARD"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookResult"
                },
                "example": {
                  "bookingId": "bk_123",
                  "bookingIds": [
                    "bk_123"
                  ],
                  "status": "PENDING",
                  "paymentMethod": "CARD",
                  "paymentStatus": "PENDING",
                  "totalCents": 45000,
                  "total": "R450",
                  "start": "2026-09-29T07:00:00.000Z",
                  "when": "Tue 29 Sep at 09:00",
                  "skipped": [],
                  "payUrl": "https://api.nogpets.com/pay/b/bk_123?t=abc.def",
                  "next": "Open the payment link to pay R450 on Payfast. The time is held; it's confirmed once paid."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/bookings/{bookingId}": {
      "get": {
        "operationId": "getBooking",
        "tags": [
          "Booking"
        ],
        "summary": "One booking",
        "security": [
          {
            "oauth2": [
              "bookings:read"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:read"
        ],
        "x-mcp-tool": {
          "name": "get_booking",
          "title": "One booking",
          "description": "One of the person's bookings by id, with its status and payment status. Use it to check whether a booking was paid or accepted."
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "responses": {
          "200": {
            "description": "The booking",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/bookings/{bookingId}/times": {
      "post": {
        "operationId": "rescheduleTimes",
        "tags": [
          "Booking"
        ],
        "summary": "Open times to move a booking to",
        "security": [
          {
            "oauth2": [
              "bookings:read"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:read"
        ],
        "x-mcp-tool": {
          "name": "find_reschedule_times",
          "title": "Find times to move a booking",
          "description": "Open times a booking can move to (its own current slot doesn't block it), over `from`–`to` (YYYY-MM-DD, default the next 7 days). Pass a slot's `start` and `resourceId` to reschedule_booking.\nTOO_LATE means it's less than 12 hours away: the person must call NogPets.\n"
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "from": {
                    "$ref": "#/components/schemas/Date"
                  },
                  "to": {
                    "$ref": "#/components/schemas/Date"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Days and their open slots",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "bookingId",
                    "durationMin",
                    "days"
                  ],
                  "properties": {
                    "bookingId": {
                      "type": "string"
                    },
                    "durationMin": {
                      "type": "integer"
                    },
                    "days": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Day"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/bookings/{bookingId}/reschedule": {
      "post": {
        "operationId": "rescheduleBooking",
        "tags": [
          "Booking"
        ],
        "summary": "Move a booking",
        "description": "Same price and payment, until 12 hours before. The booking goes back to PENDING for the business to accept the new time.",
        "security": [
          {
            "oauth2": [
              "bookings:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:write"
        ],
        "x-mcp-tool": {
          "name": "reschedule_booking",
          "title": "Move a booking",
          "description": "Moves one of the person's bookings to another open time from find_reschedule_times (`start` and `resourceId` unchanged). Same price, same payment. Allowed until 12 hours before (else TOO_LATE: they must call NogPets). Confirm the new time with the person first.\nThe booking goes back to PENDING until NogPets accepts the new time.\n"
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "start"
                ],
                "properties": {
                  "start": {
                    "type": "string",
                    "format": "date-time",
                    "description": "A slot's start from find_reschedule_times."
                  },
                  "resourceId": {
                    "type": "string",
                    "maxLength": 128
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Moved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "bookingId",
                    "start",
                    "when",
                    "status",
                    "paymentStatus"
                  ],
                  "properties": {
                    "bookingId": {
                      "type": "string"
                    },
                    "start": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "when": {
                      "type": "string"
                    },
                    "resourceId": {
                      "type": "string"
                    },
                    "status": {
                      "$ref": "#/components/schemas/BookingStatus"
                    },
                    "paymentStatus": {
                      "$ref": "#/components/schemas/PaymentStatus"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/bookings/{bookingId}/cancel": {
      "post": {
        "operationId": "cancelBooking",
        "tags": [
          "Booking"
        ],
        "summary": "Cancel a booking",
        "description": "Before it starts. A paid booking goes to REFUND_REQUESTED and NogPets refunds it. Needs `confirm: true`.",
        "security": [
          {
            "oauth2": [
              "bookings:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:write"
        ],
        "x-mcp-tool": {
          "name": "cancel_booking",
          "title": "Cancel a booking",
          "description": "Cancels one of the person's bookings before it starts. A paid booking is refunded by NogPets (REFUND_REQUESTED; 5–7 working days). `series: true` also cancels the later weeks of a repeating walk.\nAsk the person to confirm which booking first, then send `confirm: true`.\n"
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "confirm"
                ],
                "properties": {
                  "confirm": {
                    "type": "boolean",
                    "description": "true once the person said yes to cancelling; anything else is CONFIRM_REQUIRED."
                  },
                  "series": {
                    "type": "boolean",
                    "default": false,
                    "description": "Also cancel the later weeks of a repeat."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cancelled",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "cancelled"
                  ],
                  "properties": {
                    "cancelled": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "The booking ids cancelled."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/bookings/{bookingId}/pay": {
      "post": {
        "operationId": "payBooking",
        "tags": [
          "Booking"
        ],
        "summary": "A payment link",
        "description": "A fresh Payfast link (48 hours) for an unpaid card booking, for the person to open. No card data passes through here.",
        "security": [
          {
            "oauth2": [
              "bookings:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "bookings:write"
        ],
        "x-mcp-tool": {
          "name": "pay_booking",
          "title": "Get a payment link",
          "description": "Returns a payment link for one of the person's unpaid card bookings. Give the person the `payUrl` to open: they pay on Payfast (card, Instant EFT, SnapScan) themselves. You can't pay for them.\nNOTHING_TO_PAY means it's paid or cash.\n"
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "responses": {
          "200": {
            "description": "The link",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "bookingId",
                    "totalCents",
                    "total",
                    "payUrl",
                    "expiresAt"
                  ],
                  "properties": {
                    "bookingId": {
                      "type": "string"
                    },
                    "totalCents": {
                      "type": "integer"
                    },
                    "total": {
                      "type": "string"
                    },
                    "payUrl": {
                      "type": "string",
                      "format": "uri"
                    },
                    "expiresAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "next": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/products": {
      "get": {
        "operationId": "browseShop",
        "tags": [
          "Shop"
        ],
        "summary": "Shop products",
        "description": "Active products with variants and stock, and the delivery options. `SHOP_OFF` (409) while the shop is closed.",
        "x-shop": true,
        "x-mcp-tool": {
          "name": "browse_shop",
          "title": "Browse the shop",
          "description": "Lists what NogPets sells (food, toys, beds, accessories, care) with prices, sizes (variants) and stock, plus the delivery options and fees. Filter with `category`, `petType` or a search `q`.\nUse a product's `id` (and a variant's `id` when it has variants) in quote_order and buy.\n"
        },
        "parameters": [
          {
            "name": "category",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "FOOD",
                "TOYS",
                "BEDS",
                "ACCESSORIES",
                "CARE"
              ]
            }
          },
          {
            "name": "petType",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "DOG",
                "CAT"
              ]
            }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Words to look for in the name, blurb and tags.",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Products and delivery options",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Catalogue"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/orders/quote": {
      "post": {
        "operationId": "quoteOrder",
        "tags": [
          "Shop"
        ],
        "summary": "Price a shop order",
        "description": "The server's prices, delivery, discount and total. Never fails on stock or a bad code; it reports them in `problems`, `discountError`, `shippingError`.",
        "x-shop": true,
        "x-mcp-tool": {
          "name": "quote_order",
          "title": "Price a shop order",
          "description": "Prices a shop order before buying: lines, delivery fee, discount, total, and any problems (out of stock, bad code, delivery not possible to that postal code). Needs `items` [{productId, variantId?, qty}] and `shippingMethod` (from browse_shop; `visit` rides along with the person's next booking and needs `bookingId` and a token), plus `postalCode` for local delivery.\nShow the person the total before buy.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderQuoteRequest"
              },
              "example": {
                "items": [
                  {
                    "productId": "kibble-lamb",
                    "variantId": "2kg",
                    "qty": 1
                  }
                ],
                "shippingMethod": "courier"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The quote",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrderQuote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/orders": {
      "post": {
        "operationId": "buy",
        "tags": [
          "Shop"
        ],
        "summary": "Place a shop order",
        "description": "Reprices, checks stock, and places the order. Card → PENDING_PAYMENT with a `payUrl`\n(the order's page with a Pay button). Cash (only add-ons to the person's own cash\nbooking, `visit`) needs `confirm: true`.\n",
        "x-shop": true,
        "security": [
          {
            "oauth2": [
              "orders:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "orders:write"
        ],
        "x-mcp-tool": {
          "name": "buy",
          "title": "Buy from the shop",
          "description": "Places a shop order for the person. Same inputs as quote_order, plus `shipTo` {line1, suburb?, city, province?, postalCode, notes?} unless `shippingMethod` is `visit`. Show the person the items and total from quote_order and get a yes first.\nCard (default): the result has `payUrl`, the order's page. Give it to the person to pay on Payfast; unpaid orders close after 24 hours.\nCash is only for add-ons to their own cash booking (`shippingMethod: visit`, `bookingId`) and needs `confirm: true`.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The order",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrderResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/directory": {
      "get": {
        "operationId": "findPetBusinesses",
        "tags": [
          "Directory"
        ],
        "summary": "Pet businesses near Stellenbosch",
        "description": "Published listings of groomers, pet stores, vets, boarding, daycare, walkers and trainers, from public sources. NogPets is listed like everyone else (`isNogPets`).",
        "x-mcp-tool": {
          "name": "find_pet_businesses",
          "title": "Find pet businesses",
          "description": "Searches the NogPets directory of pet businesses in and around Stellenbosch (Somerset West, Strand, Paarl, Franschhoek, Kuils River): groomers (salon and mobile), pet stores, vets, boarding, daycare, walkers, trainers. Filter by `town`, `category` or words in `q`.\nFacts only (name, area, phone, website, hours, services, prices only where the business publishes them). Use it when the person wants other options or something NogPets doesn't do, such as a vet.\n"
        },
        "parameters": [
          {
            "name": "town",
            "in": "query",
            "description": "A town slug, e.g. stellenbosch, somerset-west, strand, paarl, franschhoek, kuils-river.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9-]{2,40}$"
            }
          },
          {
            "name": "category",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DirectoryCategory"
            }
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Listings",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "listings"
                  ],
                  "properties": {
                    "listings": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Listing"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/v1/business/applications": {
      "get": {
        "operationId": "getSignupStatus",
        "tags": [
          "Business"
        ],
        "summary": "A business sign-up's checklist and status",
        "description": "The person's business application (the one named, else the newest) with its checklist, what's missing and the signed status page link; plus a list of all their applications.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "get_signup_status",
          "title": "Business sign-up progress",
          "description": "Business sign-up, any step: shows the pet business's NogPets application (the one given by applicationId, else the newest) with its checklist (details, services, area, resources, payfast, staff, listing), `readyToSubmit`, `next` (what to do now) and `statusUrl`, a page the person can open to see progress.\nUse it to pick up where a sign-up left off, or after submitting to see whether NogPets approved it (status DRAFT → SUBMITTED → APPROVED or REJECTED with `reviewNote`).\n"
        },
        "parameters": [
          {
            "name": "applicationId",
            "in": "query",
            "description": "The application; left out, the newest.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{1,128}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The application (null when there is none) and the list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "application"
                  ],
                  "properties": {
                    "application": {
                      "oneOf": [
                        {
                          "type": "null"
                        },
                        {
                          "$ref": "#/components/schemas/BusinessApplication"
                        }
                      ]
                    },
                    "applications": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "applicationId": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "status": {
                            "$ref": "#/components/schemas/ApplicationStatus"
                          }
                        }
                      }
                    },
                    "next": {
                      "type": "string"
                    },
                    "welcomeUrl": {
                      "type": "string",
                      "format": "uri"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "post": {
        "operationId": "startBusinessSignup",
        "tags": [
          "Business"
        ],
        "summary": "Start (or update) a pet business's sign-up",
        "description": "Step 1 of registering a pet-care business on NogPets. Creates a DRAFT application owned by the signed-in person, or updates its details when `applicationId` is given. Nothing is public until NogPets approves it.\nThe order: startBusinessSignup → addBusinessServices → setServiceArea → addResources → inviteStaff (optional) → connectPayfast (optional) → getSignupStatus → submitBusinessForReview.\n",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "start_business_signup",
          "title": "Register a pet business (step 1)",
          "description": "For a pet grooming, dog walking or pet sitting BUSINESS that wants to join NogPets (not for booking a groom). Step 1 of 8: creates the business's application with its details; call again with `applicationId` to change them.\nWhat they get once NogPets approves: clients book them in the NogPets app, on WhatsApp and through assistants; a staff app (Today, calendar, services, vans and walkers, manual bookings); a listing in the NogPets directory; card payments through Payfast split payments. Pricing for businesses isn't published: tell them \"talk to NogPets\" (hello@nogpets.com); never quote a fee.\nAsk the person for: business name (and trading name if different), their own name, phone, email, website, the town they're based in and suburbs they serve, what they offer (`categories`: grooming, walking, sitting), whether they're MOBILE (they go to the client), a SALON, or BOTH, and how many vans and staff. Don't invent any of it.\nIf the answer has `possibleListings`, the business may already be in the NogPets directory: ask whether one is theirs and pass its id as `claimListingId`.\nThen: add_business_services → set_service_area → add_resources → invite_staff (optional) → connect_payfast (optional) → get_signup_status → submit_business_for_review. Give the person `statusUrl` so they can watch progress.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BusinessDetails"
              },
              "example": {
                "businessName": "Happy Paws Grooming (Pty) Ltd",
                "tradingName": "Happy Paws Mobile Grooming",
                "contactName": "Anna Botha",
                "phone": "082 555 0101",
                "email": "anna@happypaws.example",
                "website": "happypaws.example",
                "town": "Somerset West",
                "suburbs": [
                  "Heldervue",
                  "Helderberg Estate"
                ],
                "categories": [
                  "grooming"
                ],
                "mode": "MOBILE",
                "vans": 1,
                "staff": 2,
                "whatsappOptIn": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated application",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "201": {
            "description": "The new application",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/business/applications/{applicationId}/services": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApplicationId"
        }
      ],
      "post": {
        "operationId": "addBusinessServices",
        "tags": [
          "Business"
        ],
        "summary": "Add services and prices to a sign-up",
        "description": "Step 2. Adds or replaces the business's services (the NogPets service model: one PRIMARY per booking per pet plus EXTRAs, prices by weight and coat tiers, durations), or starts from NogPets' own menu with `useTemplate`.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "add_business_services",
          "title": "Business sign-up: services and prices (step 2)",
          "description": "Business sign-up step 2: the services the business sells, with prices in cents (R380 = 38000) and durations in minutes. Each line they offer needs at least one PRIMARY service (the main thing booked: a full groom, a 60-minute walk); EXTRAs are add-ons (nails, teeth, flea wash).\nPrices can depend on the pet: `tiers` [{petType?, coat?, minKg?, maxKg?, priceCents, durationMin}], first match wins, minKg inclusive, maxKg exclusive; `priceCents`/`durationMin` are the fallback. Walks can be group walks (`groupSize` > 1).\nFastest start: `useTemplate: true` copies NogPets' own menu for their lines (optionally `priceAdjustPct`, e.g. -10), marked fromTemplate; then go through the prices with the person and send changes by service `id`. Sending a service with an existing `id` (or the same name) replaces it; `removeIds` deletes; `replace: true` starts over. Never make up prices: ask.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BusinessServicesRequest"
              },
              "example": {
                "services": [
                  {
                    "category": "grooming",
                    "kind": "PRIMARY",
                    "name": "Full groom",
                    "blurb": "Bath, cut, blow-dry, nails and ears.",
                    "petTypes": [
                      "DOG"
                    ],
                    "priceCents": 40000,
                    "durationMin": 60,
                    "tiers": [
                      {
                        "maxKg": 10,
                        "priceCents": 40000,
                        "durationMin": 60
                      },
                      {
                        "minKg": 10,
                        "maxKg": 25,
                        "priceCents": 52000,
                        "durationMin": 75
                      },
                      {
                        "minKg": 25,
                        "priceCents": 68000,
                        "durationMin": 90
                      }
                    ]
                  },
                  {
                    "category": "grooming",
                    "kind": "EXTRA",
                    "name": "Nail clip",
                    "priceCents": 8000,
                    "durationMin": 10
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The application",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/business/applications/{applicationId}/area": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApplicationId"
        }
      ],
      "post": {
        "operationId": "setServiceArea",
        "tags": [
          "Business"
        ],
        "summary": "Set where the business works",
        "description": "Step 3. Sets the whole service area (each call replaces it) as proposed territories, PENDING until NogPets approves them.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "set_service_area",
          "title": "Business sign-up: service area (step 3)",
          "description": "Business sign-up step 3: where the business works, sent whole each time (it replaces the last one). Give `suburbs` as [{name, postalCode}] (the 4-digit postal code makes the area bookable; a suburb NogPets already knows borrows its shape), and/or `postalCodes`, and/or a `center` {lat, lng} with `radiusKm` (e.g. 10) and a `centerName`.\nThe answer lists the proposed areas (PENDING until NogPets approves) and `overlaps` with areas other businesses already serve: that's fine, NogPets decides who books where. A checklist item that says \"Needs a postal code\" means ask the person for it.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ServiceAreaRequest"
              },
              "example": {
                "suburbs": [
                  {
                    "name": "Heldervue",
                    "postalCode": "7130"
                  },
                  {
                    "name": "Helderberg Estate",
                    "postalCode": "7130"
                  }
                ],
                "center": {
                  "lat": -34.0766,
                  "lng": 18.8433
                },
                "radiusKm": 8,
                "centerName": "Somerset West"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The application",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/business/applications/{applicationId}/resources": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApplicationId"
        }
      ],
      "post": {
        "operationId": "addResources",
        "tags": [
          "Business"
        ],
        "summary": "Add vans, walkers and sitters with working hours",
        "description": "Step 4. The things clients book a time on (a grooming van, a walker, a sitter), each with its working week.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "add_resources",
          "title": "Business sign-up: vans, walkers, sitters (step 4)",
          "description": "Business sign-up step 4: what clients book time on. `kind` VAN does grooming (a salon's grooming table is a VAN too for now), WALKER does walks, SITTER does sitting. Each needs a `name` (\"Van 1\", \"Thandi\") and a `week`: {mon: [{start: \"08:00\", end: \"16:00\"}], …} in local time; leave out days off. Optional: `slotStepMin` (30), `travelBufferMin` (15 for vans).\nEvery line the business offers needs at least one. Same `id` or name replaces; `removeIds` deletes; `replace: true` starts over. Ask for the real hours; don't assume.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ResourcesRequest"
              },
              "example": {
                "resources": [
                  {
                    "kind": "VAN",
                    "name": "Van 1",
                    "week": {
                      "mon": [
                        {
                          "start": "08:00",
                          "end": "16:00"
                        }
                      ],
                      "tue": [
                        {
                          "start": "08:00",
                          "end": "16:00"
                        }
                      ],
                      "wed": [
                        {
                          "start": "08:00",
                          "end": "16:00"
                        }
                      ],
                      "thu": [
                        {
                          "start": "08:00",
                          "end": "16:00"
                        }
                      ],
                      "fri": [
                        {
                          "start": "08:00",
                          "end": "14:00"
                        }
                      ]
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The application",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/business/applications/{applicationId}/staff": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApplicationId"
        }
      ],
      "post": {
        "operationId": "inviteStaff",
        "tags": [
          "Business"
        ],
        "summary": "Invite staff",
        "description": "Step 5 (optional). Staff to invite once the business is approved, by email or phone, as manager or crew.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "invite_staff",
          "title": "Business sign-up: invite staff (optional)",
          "description": "Business sign-up step 5, optional: the people who work for the business, by `email` and/or `phone`, each with a `role`: `manager` (services, resources, manual bookings) or `crew` (sees and works the bookings). The owner is already in; don't add them.\nInvites go out when NogPets approves the business: people who already have a NogPets account join straight away, the rest when they sign up with that email or number. The same email or phone replaces; `replace: true` starts over.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InviteRequest"
              },
              "example": {
                "invites": [
                  {
                    "name": "Thandi",
                    "phone": "071 555 0199",
                    "role": "crew"
                  },
                  {
                    "name": "Pieter",
                    "email": "pieter@example.com",
                    "role": "manager"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The application",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/business/applications/{applicationId}/payfast": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApplicationId"
        }
      ],
      "post": {
        "operationId": "connectPayfast",
        "tags": [
          "Business"
        ],
        "summary": "Connect a Payfast merchant (id only)",
        "description": "Step 6 (optional, needed for card payments). Stores the business's Payfast merchant id for split payments, pending NogPets' verification. Never send a merchant key or passphrase; the schema refuses any field but the id.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "connect_payfast",
          "title": "Business sign-up: Payfast (card payments)",
          "description": "Business sign-up step 6, optional but needed for card payments: the business's Payfast merchant id (digits only, on the Payfast dashboard under Settings). ONLY the merchant id: never ask for, accept or repeat the merchant key or passphrase; if the person pastes one, tell them to keep it private.\nHow money flows: the client pays through NogPets' Payfast checkout and Payfast splits the payment, sending the business its share to its own merchant account. No Payfast account yet? They sign up at payfast.io (business account, bank details, ID); skip this step and add it later. Without it, clients pay cash.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PayfastRequest"
              },
              "example": {
                "merchantId": "10012345"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The application, with `payfastInfo` to explain to the person",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/business/applications/{applicationId}/submit": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApplicationId"
        }
      ],
      "post": {
        "operationId": "submitBusinessForReview",
        "tags": [
          "Business"
        ],
        "summary": "Submit a sign-up for NogPets' review",
        "description": "The last step. Without `confirm` it answers 409 `CONFIRM_REQUIRED` with `details.summary` to show the person; with `confirm: true` the application goes to NogPets (SUBMITTED) and can't change until they answer. `NOT_READY` lists what's missing.",
        "security": [
          {
            "oauth2": [
              "business:write"
            ]
          },
          {
            "apiKey": []
          }
        ],
        "x-scopes": [
          "business:write"
        ],
        "x-scope-gated": true,
        "x-mcp-tool": {
          "name": "submit_business_for_review",
          "title": "Business sign-up: submit for review (last step)",
          "description": "Business sign-up, last step: sends the application to NogPets. First call it without `confirm`: the answer is CONFIRM_REQUIRED with a summary; show it to the person, and only once they say it's right, call again with `confirm: true`.\nAfter that it's locked while NogPets reviews it (usually within two working days). The owner gets an email (and a WhatsApp message if they opted in) when it's approved or when changes are needed; get_signup_status and `statusUrl` show where it stands. On approval they sign in to the NogPets app with the same account to manage bookings.\n"
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubmitRequest"
              },
              "example": {
                "confirm": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Submitted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessApplication"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.1, authorization code with PKCE (S256), public clients. Register with `POST /oauth/register` (RFC 7591). Access tokens last an hour; refresh tokens rotate.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.nogpets.com/oauth/authorize",
            "tokenUrl": "https://api.nogpets.com/oauth/token",
            "refreshUrl": "https://api.nogpets.com/oauth/token",
            "scopes": {
              "profile": "See your name, email and phone",
              "pets": "See and add your pets",
              "addresses": "See and add your addresses",
              "bookings:read": "See your bookings",
              "bookings:write": "Book, move, cancel and get payment links for your bookings",
              "orders:write": "Place shop orders for you to pay",
              "business:write": "Set up and manage your pet business on NogPets"
            }
          }
        }
      },
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "npk_…",
        "description": "A personal API key from the NogPets app (Profile → Connect an AI assistant). It carries the scopes chosen when it was made."
      }
    },
    "parameters": {
      "ApplicationId": {
        "name": "applicationId",
        "in": "path",
        "required": true,
        "description": "The application, from start_business_signup.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{1,128}$"
        }
      },
      "BookingId": {
        "name": "bookingId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{1,128}$"
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The input is wrong (`INVALID_ARGUMENT`, or a pricing code such as `ONE_PRIMARY`, `PET_TYPE`, `UNKNOWN_SERVICE`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "ONE_PRIMARY",
                "message": "Pick one main service for Biscuit"
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "No token, or it's expired or revoked. The `WWW-Authenticate` header points at the resource metadata.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "UNAUTHENTICATED",
                "message": "A NogPets access token or API key is needed."
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "The token lacks a scope (`INSUFFICIENT_SCOPE`, with `details.scope`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "INSUFFICIENT_SCOPE",
                "message": "This needs the bookings:write scope.",
                "details": {
                  "scope": "bookings:write"
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "Not found, or not the person's.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "NOT_FOUND",
                "message": "Booking not found"
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "A rule said no: `NO_COVERAGE`, `NO_AVAILABILITY`, `SLOT_TAKEN`, `TOO_LATE`,\n`CONFIRM_REQUIRED` (with `details.summary`), `NOTHING_TO_PAY`, `SHOP_OFF`, `STOCK`,\n`SHIPPING`, `DISCOUNT`, `CASH`, `FAILED_PRECONDITION`.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "slotTaken": {
                "value": {
                  "error": {
                    "code": "SLOT_TAKEN",
                    "message": "That time was just taken. Pick another."
                  }
                }
              },
              "confirm": {
                "value": {
                  "error": {
                    "code": "CONFIRM_REQUIRED",
                    "message": "Show the person this summary and send confirm true once they say yes.",
                    "details": {
                      "summary": "Full groom for Biscuit, Tue 29 Sep at 09:00, 14 Kromrivier Street. R440 cash on the day."
                    }
                  }
                }
              }
            }
          }
        }
      },
      "Unprocessable": {
        "description": "The place couldn't be recognised (`UNKNOWN_PLACE`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests (`RATE_LIMIT`). `Retry-After` says when to try again.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable, upper-case."
              },
              "message": {
                "type": "string",
                "description": "Short and human; safe to show."
              },
              "details": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        }
      },
      "Date": {
        "type": "string",
        "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
        "description": "A local date, YYYY-MM-DD."
      },
      "Time": {
        "type": "string",
        "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
        "description": "A local time of day, HH:MM."
      },
      "Category": {
        "type": "string",
        "pattern": "^[a-z0-9_-]{1,40}$",
        "description": "A service line id: grooming, walking or sitting.",
        "examples": [
          "grooming"
        ]
      },
      "PetType": {
        "type": "string",
        "enum": [
          "DOG",
          "CAT"
        ]
      },
      "Coat": {
        "type": "string",
        "enum": [
          "SHORT",
          "MEDIUM",
          "LONG"
        ]
      },
      "BookingStatus": {
        "type": "string",
        "enum": [
          "PENDING",
          "ACCEPTED",
          "REJECTED",
          "CANCELLED",
          "COMPLETED"
        ]
      },
      "PaymentStatus": {
        "type": "string",
        "enum": [
          "PENDING",
          "PAID",
          "CASH_DUE",
          "CASH_RECEIVED",
          "REFUND_REQUESTED",
          "REFUNDED",
          "FAILED"
        ]
      },
      "ServiceIds": {
        "type": "array",
        "minItems": 1,
        "maxItems": 10,
        "items": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{1,128}$"
        },
        "description": "Service ids from list_services; exactly one PRIMARY plus any EXTRAs of the same line. A package id (a line's `packages`) stands for its main service and extras."
      },
      "Package": {
        "type": "object",
        "required": [
          "id",
          "name",
          "includes",
          "fromCents",
          "toCents"
        ],
        "description": "A main service and extras in one choice, priced from its parts for the pet (their tiers), less bundleDiscountPct.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "blurb": {
            "type": "string"
          },
          "includes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Service ids: one PRIMARY and EXTRAs."
          },
          "bundleDiscountPct": {
            "type": "number"
          },
          "petTypes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PetType"
            }
          },
          "fromCents": {
            "type": "integer"
          },
          "toCents": {
            "type": "integer"
          }
        }
      },
      "Tier": {
        "type": "object",
        "properties": {
          "petType": {
            "$ref": "#/components/schemas/PetType"
          },
          "coat": {
            "$ref": "#/components/schemas/Coat"
          },
          "minKg": {
            "type": "number"
          },
          "maxKg": {
            "type": "number"
          },
          "priceCents": {
            "type": "integer"
          },
          "durationMin": {
            "type": "integer"
          }
        }
      },
      "Service": {
        "type": "object",
        "required": [
          "id",
          "name",
          "kind",
          "priceCents",
          "durationMin",
          "fromCents",
          "toCents"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "PRIMARY",
              "EXTRA"
            ]
          },
          "blurb": {
            "type": "string"
          },
          "petTypes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PetType"
            }
          },
          "priceCents": {
            "type": "integer",
            "description": "Base price when no tier matches."
          },
          "durationMin": {
            "type": "integer"
          },
          "fromCents": {
            "type": "integer"
          },
          "toCents": {
            "type": "integer"
          },
          "groupSize": {
            "type": "integer",
            "description": "More than 1 is a group walk sharing the slot."
          },
          "tiers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Tier"
            }
          }
        }
      },
      "Services": {
        "type": "object",
        "required": [
          "currency",
          "serviceFeeCents",
          "acceptsCash",
          "areas",
          "lines"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "const": "ZAR"
          },
          "serviceFeeCents": {
            "type": "integer",
            "description": "Added to card payments only."
          },
          "acceptsCash": {
            "type": "boolean"
          },
          "areas": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Suburbs we cover."
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "label",
                "services"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                },
                "blurb": {
                  "type": "string"
                },
                "durationMode": {
                  "type": "string",
                  "enum": [
                    "SUM",
                    "MAX"
                  ],
                  "description": "SUM: pets one after the other; MAX: together."
                },
                "repeats": {
                  "type": "boolean",
                  "description": "Can repeat weekly."
                },
                "services": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Service"
                  }
                },
                "packages": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Package"
                  }
                }
              }
            }
          }
        }
      },
      "Place": {
        "type": "object",
        "properties": {
          "suburb": {
            "type": [
              "string",
              "null"
            ]
          },
          "postalCode": {
            "type": [
              "string",
              "null"
            ]
          },
          "lat": {
            "type": [
              "number",
              "null"
            ]
          },
          "lng": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "LineCoverage": {
        "type": "object",
        "required": [
          "id",
          "label",
          "covered"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "covered": {
            "type": "boolean"
          }
        }
      },
      "Coverage": {
        "type": "object",
        "required": [
          "covered",
          "lines"
        ],
        "properties": {
          "covered": {
            "type": "boolean"
          },
          "error": {
            "type": "string",
            "enum": [
              "NO_COVERAGE",
              "UNKNOWN_PLACE"
            ]
          },
          "leadId": {
            "type": [
              "string",
              "null"
            ],
            "description": "NO_COVERAGE: the request is kept so our team can follow up."
          },
          "message": {
            "type": "string",
            "description": "NO_COVERAGE: what to tell the person."
          },
          "place": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Place"
              },
              {
                "type": "null"
              }
            ]
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LineCoverage"
            }
          },
          "nearest": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "distanceKm": {
                    "type": "number"
                  }
                }
              }
            ]
          }
        }
      },
      "PetLine": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "serviceIds"
        ],
        "description": "A saved pet (`petId`, needs a token) or one described by value (`name`, `type`, `weightKg`, `coat`).",
        "properties": {
          "petId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$"
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60
          },
          "type": {
            "$ref": "#/components/schemas/PetType"
          },
          "weightKg": {
            "type": "number",
            "exclusiveMinimum": 0,
            "exclusiveMaximum": 120
          },
          "coat": {
            "$ref": "#/components/schemas/Coat"
          },
          "serviceIds": {
            "$ref": "#/components/schemas/ServiceIds"
          },
          "packageId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$",
            "description": "A package from list_services (same as putting it in serviceIds)."
          }
        }
      },
      "QuoteRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "category",
          "pets"
        ],
        "properties": {
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "address": {
            "type": "string",
            "maxLength": 300,
            "description": "Typed address with the suburb or postal code, or \"lat,lng\"."
          },
          "lat": {
            "type": "number",
            "minimum": -90,
            "maximum": 90
          },
          "lng": {
            "type": "number",
            "minimum": -180,
            "maximum": 180
          },
          "postalCode": {
            "type": "string",
            "pattern": "^[0-9]{4}$"
          },
          "addressId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$",
            "description": "A saved address (needs a token with the addresses scope)."
          },
          "pets": {
            "type": "array",
            "minItems": 1,
            "maxItems": 6,
            "items": {
              "$ref": "#/components/schemas/PetLine"
            }
          },
          "promoCode": {
            "type": "string",
            "maxLength": 32,
            "description": "A promo code (e.g. WELCOME30). Leave it out to apply a code the person claimed on nogpets.com (matched by their mobile); send \"\" for none."
          }
        }
      },
      "TimesRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "category",
          "pets"
        ],
        "properties": {
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "address": {
            "type": "string",
            "maxLength": 300
          },
          "lat": {
            "type": "number",
            "minimum": -90,
            "maximum": 90
          },
          "lng": {
            "type": "number",
            "minimum": -180,
            "maximum": 180
          },
          "postalCode": {
            "type": "string",
            "pattern": "^[0-9]{4}$"
          },
          "addressId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$"
          },
          "pets": {
            "type": "array",
            "minItems": 1,
            "maxItems": 6,
            "items": {
              "$ref": "#/components/schemas/PetLine"
            }
          },
          "from": {
            "$ref": "#/components/schemas/Date"
          },
          "to": {
            "$ref": "#/components/schemas/Date"
          },
          "windowStart": {
            "$ref": "#/components/schemas/Time"
          },
          "windowEnd": {
            "$ref": "#/components/schemas/Time"
          },
          "stay": {
            "$ref": "#/components/schemas/Stay"
          },
          "visits": {
            "$ref": "#/components/schemas/Visits"
          },
          "promoCode": {
            "type": "string",
            "maxLength": 32,
            "description": "A promo code (e.g. WELCOME30). Leave it out to apply a code the person claimed on nogpets.com (matched by their mobile); send \"\" for none."
          }
        }
      },
      "Item": {
        "type": "object",
        "required": [
          "serviceId",
          "name",
          "kind",
          "priceCents",
          "durationMin"
        ],
        "properties": {
          "serviceId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "PRIMARY",
              "EXTRA"
            ]
          },
          "priceCents": {
            "type": "integer"
          },
          "durationMin": {
            "type": "integer"
          }
        }
      },
      "Quote": {
        "type": "object",
        "required": [
          "category",
          "durationMin",
          "pets",
          "subtotalCents",
          "cardFeeCents",
          "cardTotalCents",
          "acceptsCash",
          "summary"
        ],
        "properties": {
          "category": {
            "type": "string"
          },
          "durationMin": {
            "type": "integer"
          },
          "pets": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "name",
                "items"
              ],
              "properties": {
                "petId": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "items": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Item"
                  }
                },
                "package": {
                  "type": "object",
                  "description": "The package chosen; items are its parts at list price and savingCents comes off.",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "savingCents": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "subtotalCents": {
            "type": "integer"
          },
          "cardFeeCents": {
            "type": "integer"
          },
          "cardTotalCents": {
            "type": "integer"
          },
          "cashTotalCents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Null when cash isn't taken. After the discount."
          },
          "discount": {
            "description": "The promo code applied (cardTotalCents and cashTotalCents are after it). `auto` when it came from the person's claimed offer.",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Discount"
              }
            ]
          },
          "discountError": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "UNKNOWN",
              "INACTIVE",
              "NOT_STARTED",
              "EXPIRED",
              "USED_UP",
              "NOT_APPLICABLE",
              "NOT_FIRST",
              "LIMIT_REACHED",
              "MIN_SUBTOTAL",
              null
            ],
            "description": "Why promoCode can't be used. The quote is then without it."
          },
          "acceptsCash": {
            "type": "boolean"
          },
          "group": {
            "type": [
              "object",
              "null"
            ],
            "description": "A group walk: {serviceId, size, dogs}."
          },
          "summary": {
            "type": "string"
          }
        }
      },
      "Slot": {
        "type": "object",
        "required": [
          "start",
          "time",
          "resourceId"
        ],
        "properties": {
          "start": {
            "type": "string",
            "format": "date-time"
          },
          "time": {
            "type": "string",
            "description": "Local HH:MM."
          },
          "resourceId": {
            "type": "string"
          }
        }
      },
      "Day": {
        "type": "object",
        "required": [
          "date",
          "label",
          "slots"
        ],
        "properties": {
          "date": {
            "$ref": "#/components/schemas/Date"
          },
          "label": {
            "type": "string",
            "examples": [
              "Tue 29 Sep"
            ]
          },
          "slots": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Slot"
            }
          }
        }
      },
      "Times": {
        "type": "object",
        "required": [
          "durationMin",
          "quote",
          "days"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "NO_AVAILABILITY"
            ]
          },
          "durationMin": {
            "type": "integer"
          },
          "quote": {
            "$ref": "#/components/schemas/Quote"
          },
          "days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Day"
            }
          },
          "mode": {
            "type": "string",
            "enum": [
              "SLOT",
              "STAY"
            ],
            "description": "STAY: an overnight service; book it with `stay`, not a start time."
          },
          "multiDay": {
            "type": "boolean",
            "description": "The service can also be booked as visits over several days (`visits`)."
          },
          "windows": {
            "type": [
              "object",
              "null"
            ],
            "description": "The windows to offer: STAY {evening, morning}, multiDay {visit}; each [{id, label, start, end, text}]."
          },
          "plan": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Plan"
              }
            ]
          }
        }
      },
      "Stay": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "startDate",
          "nights"
        ],
        "description": "An overnight stay. One booking a night, from the evening visit to the next morning's check.",
        "properties": {
          "startDate": {
            "$ref": "#/components/schemas/Date"
          },
          "nights": {
            "type": "integer",
            "minimum": 1,
            "maximum": 14
          },
          "eveningWindow": {
            "type": "string",
            "description": "A window id from `windows.evening` (or \"17:00-19:00\"). Default the first."
          },
          "morningWindow": {
            "type": "string",
            "description": "A window id from `windows.morning`. Default the first."
          },
          "notes": {
            "type": "string",
            "maxLength": 1000,
            "description": "Keys, feeding, meds."
          }
        }
      },
      "Visits": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "startDate",
          "days"
        ],
        "description": "Visits over several days. One booking a visit, at the first free time in its window.",
        "properties": {
          "startDate": {
            "$ref": "#/components/schemas/Date"
          },
          "days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 14
          },
          "perDay": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3
          },
          "windows": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "type": "string"
            },
            "description": "A window id from `windows.visit` per visit of the day, in order."
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          }
        }
      },
      "Plan": {
        "type": "object",
        "required": [
          "mode",
          "label",
          "ok",
          "spans"
        ],
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "STAY",
              "MULTI_VISIT"
            ]
          },
          "label": {
            "type": "string",
            "examples": [
              "3 nights, 12–15 Oct, evening visits 17:00–19:00, morning checks 07:00–09:00"
            ]
          },
          "ok": {
            "type": "boolean",
            "description": "Every night or visit has a free sitter."
          },
          "spans": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "$ref": "#/components/schemas/Date"
                },
                "start": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date-time"
                },
                "end": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date-time"
                },
                "window": {
                  "type": "string"
                },
                "free": {
                  "type": "boolean"
                }
              }
            }
          }
        }
      },
      "Profile": {
        "type": "object",
        "required": [
          "uid",
          "name",
          "scopes"
        ],
        "properties": {
          "uid": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "pets": {
            "type": "integer"
          },
          "addresses": {
            "type": "integer"
          },
          "client": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "Pet": {
        "type": "object",
        "required": [
          "id",
          "name",
          "type",
          "weightKg",
          "coat"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "$ref": "#/components/schemas/PetType"
          },
          "breed": {
            "type": "string"
          },
          "weightKg": {
            "type": "number"
          },
          "coat": {
            "$ref": "#/components/schemas/Coat"
          },
          "birthYear": {
            "type": [
              "integer",
              "null"
            ]
          },
          "notes": {
            "type": "string"
          }
        }
      },
      "NewPet": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name",
          "type",
          "weightKg",
          "coat"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60
          },
          "type": {
            "$ref": "#/components/schemas/PetType"
          },
          "weightKg": {
            "type": "number",
            "exclusiveMinimum": 0,
            "exclusiveMaximum": 120
          },
          "coat": {
            "$ref": "#/components/schemas/Coat"
          },
          "breed": {
            "type": "string",
            "maxLength": 80
          },
          "birthYear": {
            "type": "integer",
            "minimum": 1990,
            "maximum": 2100
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          }
        }
      },
      "Address": {
        "type": "object",
        "required": [
          "id",
          "label",
          "line1"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "line1": {
            "type": "string"
          },
          "suburb": {
            "type": "string"
          },
          "city": {
            "type": "string"
          },
          "postalCode": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          },
          "isDefault": {
            "type": "boolean"
          }
        }
      },
      "NewAddress": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "line1"
        ],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40
          },
          "line1": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "suburb": {
            "type": "string",
            "maxLength": 80
          },
          "city": {
            "type": "string",
            "maxLength": 80
          },
          "postalCode": {
            "type": "string",
            "pattern": "^[0-9]{4}$"
          },
          "lat": {
            "type": "number",
            "minimum": -90,
            "maximum": 90
          },
          "lng": {
            "type": "number",
            "minimum": -180,
            "maximum": 180
          },
          "notes": {
            "type": "string",
            "maxLength": 500
          },
          "isDefault": {
            "type": "boolean"
          }
        }
      },
      "BookRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "category",
          "addressId",
          "pets",
          "paymentMethod"
        ],
        "description": "One visit: start and resourceId from find_times. An overnight stay: `stay`. Visits over several days: `visits`.",
        "properties": {
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "addressId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$"
          },
          "pets": {
            "type": "array",
            "minItems": 1,
            "maxItems": 6,
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "petId",
                "serviceIds"
              ],
              "properties": {
                "petId": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]{1,128}$"
                },
                "serviceIds": {
                  "$ref": "#/components/schemas/ServiceIds"
                },
                "packageId": {
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]{1,128}$"
                }
              }
            }
          },
          "start": {
            "type": "string",
            "format": "date-time",
            "description": "A slot's start from find_times, unchanged."
          },
          "resourceId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$",
            "description": "The slot's resourceId, unchanged."
          },
          "paymentMethod": {
            "type": "string",
            "enum": [
              "CARD",
              "CASH"
            ]
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          },
          "repeat": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "freq",
              "days"
            ],
            "description": "Walks only. Books the weeks up to 4 weeks out now; the rest follow.",
            "properties": {
              "freq": {
                "type": "string",
                "const": "WEEKLY"
              },
              "days": {
                "type": "array",
                "minItems": 1,
                "maxItems": 7,
                "items": {
                  "type": "string",
                  "enum": [
                    "mon",
                    "tue",
                    "wed",
                    "thu",
                    "fri",
                    "sat",
                    "sun"
                  ]
                }
              },
              "until": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/Date"
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          },
          "confirm": {
            "type": "boolean",
            "description": "Cash only: true once the person said yes to the summary."
          },
          "stay": {
            "$ref": "#/components/schemas/Stay"
          },
          "visits": {
            "$ref": "#/components/schemas/Visits"
          },
          "promoCode": {
            "type": "string",
            "maxLength": 32,
            "description": "The code from the quote. A code that no longer applies is a 409 PROMO (details.reason). Left out, a claimed offer is applied if it fits."
          }
        }
      },
      "Discount": {
        "type": "object",
        "required": [
          "code",
          "amountCents"
        ],
        "properties": {
          "code": {
            "type": "string",
            "examples": [
              "WELCOME30"
            ]
          },
          "amountCents": {
            "type": "integer",
            "description": "What came off the price (never the card fee)."
          },
          "auto": {
            "type": "boolean"
          }
        }
      },
      "BookResult": {
        "type": "object",
        "required": [
          "bookingId",
          "bookingIds",
          "status",
          "paymentMethod",
          "paymentStatus",
          "totalCents",
          "total",
          "start",
          "when",
          "payUrl",
          "next"
        ],
        "properties": {
          "bookingId": {
            "type": "string"
          },
          "bookingIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Every week booked (repeats)."
          },
          "seriesId": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/BookingStatus"
          },
          "paymentMethod": {
            "type": "string",
            "enum": [
              "CARD",
              "CASH"
            ]
          },
          "paymentStatus": {
            "$ref": "#/components/schemas/PaymentStatus"
          },
          "totalCents": {
            "type": "integer"
          },
          "total": {
            "type": "string"
          },
          "discount": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Discount"
              }
            ]
          },
          "plan": {
            "type": [
              "object",
              "null"
            ],
            "description": "A stay or visits over days: {mode, label, count}."
          },
          "start": {
            "type": "string",
            "format": "date-time"
          },
          "when": {
            "type": "string"
          },
          "skipped": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Repeat weeks that were full."
          },
          "payUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Card: the link the person opens to pay (48 hours)."
          },
          "next": {
            "type": "string",
            "description": "What to tell the person."
          }
        }
      },
      "Booking": {
        "type": "object",
        "required": [
          "id",
          "category",
          "start",
          "status",
          "paymentStatus",
          "paymentMethod",
          "totalCents"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "what": {
            "type": "string",
            "examples": [
              "Biscuit: Full groom, Nails"
            ]
          },
          "start": {
            "type": "string",
            "format": "date-time"
          },
          "end": {
            "type": "string",
            "format": "date-time"
          },
          "when": {
            "type": "string"
          },
          "address": {
            "type": "object",
            "properties": {
              "label": {
                "type": "string"
              },
              "line1": {
                "type": "string"
              },
              "suburb": {
                "type": "string"
              }
            }
          },
          "status": {
            "$ref": "#/components/schemas/BookingStatus"
          },
          "paymentMethod": {
            "type": "string",
            "enum": [
              "CARD",
              "CASH"
            ]
          },
          "paymentStatus": {
            "$ref": "#/components/schemas/PaymentStatus"
          },
          "totalCents": {
            "type": "integer"
          },
          "total": {
            "type": "string"
          },
          "discount": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Discount"
              }
            ]
          },
          "crew": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "seriesId": {
            "type": [
              "string",
              "null"
            ]
          },
          "canPay": {
            "type": "boolean"
          },
          "source": {
            "type": "string"
          }
        }
      },
      "Variant": {
        "type": "object",
        "required": [
          "id",
          "label",
          "priceCents",
          "inStock"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "priceCents": {
            "type": "integer"
          },
          "inStock": {
            "type": "boolean"
          }
        }
      },
      "Product": {
        "type": "object",
        "required": [
          "id",
          "name",
          "category",
          "priceCents",
          "inStock"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "blurb": {
            "type": "string"
          },
          "petTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "priceCents": {
            "type": "integer"
          },
          "compareAtCents": {
            "type": [
              "integer",
              "null"
            ]
          },
          "variants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Variant"
            }
          },
          "inStock": {
            "type": "boolean"
          },
          "deliverWithVisit": {
            "type": "boolean"
          },
          "image": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          }
        }
      },
      "ShippingMethod": {
        "type": "object",
        "required": [
          "id",
          "label",
          "feeCents"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "feeCents": {
            "type": "integer"
          },
          "eta": {
            "type": "string"
          },
          "postalCodes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "Catalogue": {
        "type": "object",
        "required": [
          "products",
          "shipping"
        ],
        "properties": {
          "products": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Product"
            }
          },
          "shipping": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ShippingMethod"
            }
          },
          "freeShippingOverCents": {
            "type": [
              "integer",
              "null"
            ]
          },
          "note": {
            "type": "string"
          }
        }
      },
      "OrderItem": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "productId",
          "qty"
        ],
        "properties": {
          "productId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$"
          },
          "variantId": {
            "type": "string",
            "maxLength": 64
          },
          "qty": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20
          }
        }
      },
      "OrderQuoteRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "items",
          "shippingMethod"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/OrderItem"
            }
          },
          "shippingMethod": {
            "type": "string",
            "maxLength": 40,
            "examples": [
              "courier",
              "local",
              "visit"
            ]
          },
          "postalCode": {
            "type": "string",
            "pattern": "^[0-9]{4}$"
          },
          "bookingId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$"
          },
          "discountCode": {
            "type": "string",
            "maxLength": 32
          }
        }
      },
      "OrderQuote": {
        "type": "object",
        "required": [
          "items",
          "subtotalCents",
          "shippingCents",
          "totalCents",
          "problems",
          "ok"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "subtotalCents": {
            "type": "integer"
          },
          "discount": {
            "type": [
              "object",
              "null"
            ]
          },
          "discountError": {
            "type": [
              "string",
              "null"
            ]
          },
          "shippingMethod": {
            "type": [
              "string",
              "null"
            ]
          },
          "shipping": {
            "type": [
              "object",
              "null"
            ]
          },
          "shippingCents": {
            "type": "integer"
          },
          "shippingError": {
            "type": [
              "string",
              "null"
            ]
          },
          "totalCents": {
            "type": "integer"
          },
          "problems": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "ok": {
            "type": "boolean"
          }
        }
      },
      "ShipTo": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "line1",
          "city",
          "postalCode"
        ],
        "properties": {
          "line1": {
            "type": "string",
            "maxLength": 200
          },
          "suburb": {
            "type": "string",
            "maxLength": 80
          },
          "city": {
            "type": "string",
            "maxLength": 80
          },
          "province": {
            "type": "string",
            "maxLength": 40
          },
          "postalCode": {
            "type": "string",
            "pattern": "^[0-9]{4}$"
          },
          "notes": {
            "type": "string",
            "maxLength": 500
          }
        }
      },
      "OrderRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "items",
          "shippingMethod"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/OrderItem"
            }
          },
          "shippingMethod": {
            "type": "string",
            "maxLength": 40
          },
          "postalCode": {
            "type": "string",
            "pattern": "^[0-9]{4}$"
          },
          "bookingId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$"
          },
          "discountCode": {
            "type": "string",
            "maxLength": 32
          },
          "shipTo": {
            "$ref": "#/components/schemas/ShipTo"
          },
          "paymentMethod": {
            "type": "string",
            "enum": [
              "CARD",
              "CASH"
            ],
            "default": "CARD"
          },
          "confirm": {
            "type": "boolean",
            "description": "Cash only: true once the person said yes."
          }
        }
      },
      "OrderResult": {
        "type": "object",
        "required": [
          "orderId",
          "number",
          "status",
          "totalCents",
          "total",
          "payUrl",
          "next"
        ],
        "properties": {
          "orderId": {
            "type": "string"
          },
          "number": {
            "type": "string",
            "examples": [
              "NP-1004"
            ]
          },
          "status": {
            "type": "string"
          },
          "totalCents": {
            "type": "integer"
          },
          "total": {
            "type": "string"
          },
          "payUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The order's page; a Pay button while unpaid."
          },
          "next": {
            "type": "string"
          }
        }
      },
      "DirectoryCategory": {
        "type": "string",
        "enum": [
          "GROOMER_SALON",
          "GROOMER_MOBILE",
          "PET_STORE",
          "VET",
          "BOARDING",
          "DAYCARE",
          "WALKER",
          "TRAINER"
        ]
      },
      "Listing": {
        "type": "object",
        "required": [
          "id",
          "name",
          "town",
          "categories",
          "url"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "town": {
            "type": "string"
          },
          "suburb": {
            "type": "string"
          },
          "address": {
            "type": "string"
          },
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DirectoryCategory"
            }
          },
          "services": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "priceText": {
                  "type": "string"
                }
              }
            }
          },
          "petTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "phone": {
            "type": "string"
          },
          "website": {
            "type": "string"
          },
          "hoursText": {
            "type": "string"
          },
          "blurb": {
            "type": "string"
          },
          "isNogPets": {
            "type": "boolean"
          },
          "verified": {
            "type": "boolean"
          },
          "lastChecked": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The listing on nogpets.com."
          }
        }
      },
      "ApplicationStatus": {
        "type": "string",
        "enum": [
          "DRAFT",
          "SUBMITTED",
          "APPROVED",
          "REJECTED"
        ],
        "description": "DRAFT while it's being filled in; SUBMITTED while NogPets reviews it; APPROVED (the business exists, bizId) or REJECTED (reviewNote says why; editing it makes it a DRAFT again)."
      },
      "LineId": {
        "type": "string",
        "enum": [
          "grooming",
          "walking",
          "sitting"
        ],
        "description": "A NogPets service line."
      },
      "Week": {
        "type": "object",
        "additionalProperties": false,
        "description": "Working hours per weekday, local time. Leave out days off.",
        "properties": {
          "mon": {
            "$ref": "#/components/schemas/DayRanges"
          },
          "tue": {
            "$ref": "#/components/schemas/DayRanges"
          },
          "wed": {
            "$ref": "#/components/schemas/DayRanges"
          },
          "thu": {
            "$ref": "#/components/schemas/DayRanges"
          },
          "fri": {
            "$ref": "#/components/schemas/DayRanges"
          },
          "sat": {
            "$ref": "#/components/schemas/DayRanges"
          },
          "sun": {
            "$ref": "#/components/schemas/DayRanges"
          }
        }
      },
      "DayRanges": {
        "type": "array",
        "maxItems": 6,
        "items": {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "start",
            "end"
          ],
          "properties": {
            "start": {
              "$ref": "#/components/schemas/Time"
            },
            "end": {
              "$ref": "#/components/schemas/Time"
            }
          }
        }
      },
      "BusinessDetails": {
        "type": "object",
        "additionalProperties": false,
        "description": "New application: businessName, categories, town and a phone or email are required. With applicationId: only what changes.",
        "properties": {
          "applicationId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,128}$",
            "description": "Update this application instead of starting one."
          },
          "businessName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "The registered or main business name."
          },
          "tradingName": {
            "type": "string",
            "maxLength": 120,
            "description": "The name clients know, if different."
          },
          "contactName": {
            "type": "string",
            "maxLength": 120,
            "description": "The owner or the person NogPets talks to."
          },
          "phone": {
            "type": "string",
            "maxLength": 30,
            "description": "South African number; stored as +27…"
          },
          "email": {
            "type": "string",
            "maxLength": 254
          },
          "website": {
            "type": "string",
            "maxLength": 300
          },
          "town": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "Where the business is based, e.g. Stellenbosch, Somerset West, Paarl."
          },
          "suburbs": {
            "type": "array",
            "maxItems": 40,
            "items": {
              "type": "string",
              "maxLength": 80
            },
            "description": "Suburbs they serve, as they say them (set_service_area makes them bookable areas)."
          },
          "categories": {
            "type": "array",
            "minItems": 1,
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/LineId"
            }
          },
          "mode": {
            "type": "string",
            "enum": [
              "MOBILE",
              "SALON",
              "BOTH"
            ],
            "description": "MOBILE goes to the client (what NogPets books today); SALON is at their premises; BOTH."
          },
          "vans": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "staff": {
            "type": "integer",
            "minimum": 0,
            "maximum": 500
          },
          "about": {
            "type": "string",
            "maxLength": 300,
            "description": "One or two plain sentences about the business."
          },
          "claimListingId": {
            "type": "string",
            "pattern": "^([a-z0-9-]{1,120})?$",
            "description": "A directory listing id (from possibleListings or find_pet_businesses) this business owns; \"\" unclaims."
          },
          "whatsappOptIn": {
            "type": "boolean",
            "description": "The owner agrees to NogPets WhatsApp messages about the application."
          }
        }
      },
      "TierIn": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "priceCents"
        ],
        "properties": {
          "petType": {
            "$ref": "#/components/schemas/PetType"
          },
          "coat": {
            "$ref": "#/components/schemas/Coat"
          },
          "minKg": {
            "type": "number",
            "minimum": 0,
            "maximum": 200
          },
          "maxKg": {
            "type": "number",
            "minimum": 0,
            "maximum": 200
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000
          },
          "durationMin": {
            "type": "integer",
            "minimum": 5,
            "maximum": 1440
          }
        }
      },
      "NewBusinessService": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "category",
          "kind",
          "name",
          "priceCents",
          "durationMin"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[a-z0-9-]{1,60}$",
            "description": "An existing service's id, to replace it."
          },
          "category": {
            "$ref": "#/components/schemas/LineId"
          },
          "kind": {
            "type": "string",
            "enum": [
              "PRIMARY",
              "EXTRA"
            ]
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "blurb": {
            "type": "string",
            "maxLength": 300
          },
          "petTypes": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/PetType"
            }
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Rand in cents; the price when no tier matches."
          },
          "durationMin": {
            "type": "integer",
            "minimum": 5,
            "maximum": 1440
          },
          "tiers": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/TierIn"
            }
          },
          "groupSize": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20,
            "description": "Walks: more than 1 lets that many dogs share a slot."
          },
          "sort": {
            "type": "integer",
            "minimum": 0,
            "maximum": 999
          }
        }
      },
      "BusinessServicesRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "services": {
            "type": "array",
            "maxItems": 60,
            "items": {
              "$ref": "#/components/schemas/NewBusinessService"
            }
          },
          "useTemplate": {
            "type": "boolean",
            "description": "Copy NogPets' own menu for the business's lines first."
          },
          "templateCategories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LineId"
            },
            "description": "Only these lines from the template."
          },
          "priceAdjustPct": {
            "type": "integer",
            "minimum": -50,
            "maximum": 200,
            "description": "Move the template's prices by this percentage (rounded to R5)."
          },
          "replace": {
            "type": "boolean",
            "description": "Drop the services there first."
          },
          "removeIds": {
            "type": "array",
            "maxItems": 60,
            "items": {
              "type": "string",
              "maxLength": 60
            }
          }
        }
      },
      "ServiceAreaRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "suburbs": {
            "type": "array",
            "maxItems": 30,
            "items": {
              "oneOf": [
                {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 80
                },
                {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "name"
                  ],
                  "properties": {
                    "name": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 80
                    },
                    "postalCode": {
                      "type": "string",
                      "pattern": "^[0-9]{4}$"
                    }
                  }
                }
              ]
            }
          },
          "postalCodes": {
            "type": "array",
            "maxItems": 60,
            "items": {
              "type": "string",
              "pattern": "^[0-9]{4}$"
            }
          },
          "center": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "lat",
              "lng"
            ],
            "properties": {
              "lat": {
                "type": "number",
                "minimum": -35,
                "maximum": -22
              },
              "lng": {
                "type": "number",
                "minimum": 16,
                "maximum": 33
              }
            }
          },
          "radiusKm": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 50
          },
          "centerName": {
            "type": "string",
            "maxLength": 80,
            "description": "What to call the radius area, e.g. the town."
          }
        }
      },
      "NewResource": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "kind",
          "name",
          "week"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[a-z0-9-]{1,60}$",
            "description": "An existing resource's id, to replace it."
          },
          "kind": {
            "type": "string",
            "enum": [
              "VAN",
              "WALKER",
              "SITTER"
            ]
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LineId"
            },
            "description": "Defaults to the lines this kind does."
          },
          "week": {
            "$ref": "#/components/schemas/Week"
          },
          "slotStepMin": {
            "type": "integer",
            "minimum": 5,
            "maximum": 240
          },
          "travelBufferMin": {
            "type": "integer",
            "minimum": 0,
            "maximum": 240
          }
        }
      },
      "ResourcesRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "resources": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/NewResource"
            }
          },
          "replace": {
            "type": "boolean"
          },
          "removeIds": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string",
              "maxLength": 60
            }
          }
        }
      },
      "InviteRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "invites"
        ],
        "properties": {
          "invites": {
            "type": "array",
            "maxItems": 30,
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "role"
              ],
              "properties": {
                "name": {
                  "type": "string",
                  "maxLength": 80
                },
                "email": {
                  "type": "string",
                  "maxLength": 254
                },
                "phone": {
                  "type": "string",
                  "maxLength": 30
                },
                "role": {
                  "type": "string",
                  "enum": [
                    "manager",
                    "crew"
                  ]
                }
              }
            }
          },
          "replace": {
            "type": "boolean"
          }
        }
      },
      "PayfastRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "merchantId"
        ],
        "properties": {
          "merchantId": {
            "type": "string",
            "pattern": "^[0-9]{5,12}$",
            "description": "The Payfast merchant id, digits only. Never the merchant key or passphrase."
          }
        }
      },
      "SubmitRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "confirm": {
            "type": "boolean",
            "description": "true once the person has seen the summary and said yes."
          }
        }
      },
      "ChecklistItem": {
        "type": "object",
        "required": [
          "id",
          "label",
          "done",
          "required"
        ],
        "properties": {
          "id": {
            "type": "string",
            "enum": [
              "details",
              "services",
              "area",
              "resources",
              "payfast",
              "staff",
              "listing"
            ]
          },
          "label": {
            "type": "string"
          },
          "done": {
            "type": "boolean"
          },
          "required": {
            "type": "boolean"
          },
          "detail": {
            "type": "string"
          }
        }
      },
      "BusinessApplication": {
        "type": "object",
        "required": [
          "applicationId",
          "status",
          "business",
          "checklist",
          "readyToSubmit",
          "next"
        ],
        "properties": {
          "applicationId": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/ApplicationStatus"
          },
          "business": {
            "type": "object",
            "additionalProperties": true
          },
          "listing": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "town": {
                "type": "string"
              }
            }
          },
          "services": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "areas": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Proposed territories, PENDING until approved."
          },
          "overlaps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "area": {
                  "type": "string"
                },
                "territory": {
                  "type": "string"
                },
                "grantedTo": {
                  "type": "string"
                }
              }
            }
          },
          "resources": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "invites": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "payfast": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "merchantId": {
                "type": "string"
              },
              "split": {
                "type": "boolean"
              },
              "status": {
                "type": "string"
              }
            }
          },
          "whatsappOptIn": {
            "type": "boolean"
          },
          "plan": {
            "type": "null",
            "description": "Reserved: NogPets' plan for the business. Not set yet; pricing is by conversation."
          },
          "checklist": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChecklistItem"
            }
          },
          "readyToSubmit": {
            "type": "boolean"
          },
          "reviewNote": {
            "type": "string",
            "description": "Why NogPets asked for changes."
          },
          "bizId": {
            "type": "string",
            "description": "The business, once approved."
          },
          "possibleListings": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "Directory listings that may be this business: offer to claim one."
          },
          "payfastInfo": {
            "type": "string"
          },
          "summary": {
            "type": "string"
          },
          "statusUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "A page the person can open to see the checklist and status."
          },
          "welcomeUrl": {
            "type": "string",
            "format": "uri",
            "description": "What NogPets offers businesses, for the person to read."
          },
          "next": {
            "type": "string",
            "description": "What to do now."
          }
        }
      }
    }
  }
}
