{
  "openapi": "3.1.0",
  "info": {
    "title": "Workeey Quote Links API",
    "version": "2026-11-06",
    "summary": "Turn a quote into a web page the customer approves online. Free; no account for anyone.",
    "description": "Quote Links is a free Workeey tool. A tradesperson, or an AI helping one, sends a quote and gets back two links: a **customer link** (the customer views the quote, picks an option and add-ons, types their name, and approves, declines or asks for changes) and a **private manage link** (it shows the answer and can revise, cancel or delete the quote).\n\nQuote Links is separate from the Workeey app: it is not the estimates inside the app, and it needs no Workeey account.\n\n**Use this API only when the user asks you to publish the quote for them:** /create publishes at once, with no draft for them to check. Otherwise give them a ready-made link (https://www.workeey.com/quote-links/new#draft=…): it sends nothing, and they check the quote and press Create approval link themselves.\n\n**Authentication.** Creating a quote needs nothing. Everything under /manage needs the manage token as `Authorization: Bearer <manage_token>`. The manage token works like a password: give it and the manage link only to the business, never to the customer.\n\n**Rules for every quote.** Use only prices the business gave you, or a starting price from Workeey's price book that they agreed to (GET https://www.workeey.com/api/price-check?job=<the job in words>&where=<City, ST>, or trade=<trade>&state=<two-letter code> for every job of a trade, with its sources and honesty line); never search the web for prices. No links, email addresses or payment details (Zelle, Venmo, Cash App, PayPal, wire, ACH, routing or account numbers, crypto, $cashtags). Email addresses are allowed only in business.email, long digit runs only in business.phone. The customer is a display name only: never their phone, email or address. Workeey adds up the totals and applies tax only as supplied (a rate, an amount or a note); it never decides tax rules.\n\n**Errors.** A 422 lists every problem as `{field, problem, fix}`: apply the fixes and send again. POST /validate checks a quote the same way and stores nothing.\n\n**No tools?** An AI that cannot call APIs can build a ready-made draft link instead: `https://www.workeey.com/quote-links/new#draft=` followed by the quote JSON passed through encodeURIComponent. The business opens it, checks it and presses Create approval link. The full guide, with two complete examples: https://www.workeey.com/quote-links/for-ai/\n\nWorkeey does not verify businesses or quotes. The customer's answer is kept as a record of approval: which version, which option, the amount, the typed name and the time.",
    "termsOfService": "https://www.workeey.com/terms",
    "contact": {
      "name": "Workeey support",
      "email": "support@workeey.com",
      "url": "https://www.workeey.com/contact/"
    }
  },
  "externalDocs": {
    "description": "Quote Links for AI assistants: when to use it, the draft link, the API, MCP and the rules",
    "url": "https://www.workeey.com/quote-links/for-ai/"
  },
  "servers": [
    {
      "url": "https://www.workeey.com/api/quote-links",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Quotes",
      "description": "For the business and the AI helping it: create, validate, check, revise, cancel and delete."
    },
    {
      "name": "Private link by email",
      "description": "Optional: a confirmed email address gets notified of answers and can recover lost private links."
    },
    {
      "name": "Customer page",
      "description": "What the customer page at /q/<customer_token> calls. Reading the public view is harmless; open, respond and report are for the customer's own browser only."
    }
  ],
  "paths": {
    "/create": {
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "createQuoteLink",
        "summary": "Create a Quote Link",
        "description": "Publishes the quote at once and returns the customer link and the private manage link. Give customer_url to the business to send to its customer; give manage_url only to the business and tell them to save it (it works like a password). Rate limits: 20 creates per address per day, 200 per day in all. Browsers on other sites may call it (CORS).",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Optional. A new random UUID for each new quote (in a shell, \"$(uuidgen)\"). Send the same key again only to retry that same quote: within 24 hours, from the same address and User-Agent, the retry gets the first answer back, marked Idempotent-Replayed: true, instead of making a second quote, so a retry after a timeout is safe. A key already used for a different quote is refused with 422 and error idempotency_conflict.",
            "schema": {
              "type": "string",
              "minLength": 16,
              "maxLength": 80,
              "pattern": "^[A-Za-z0-9_.:-]{16,80}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteInput"
              },
              "examples": {
                "waterHeater": {
                  "summary": "Two options, an add-on and a tax rate",
                  "value": {
                    "business": {
                      "name": "Rivera Plumbing",
                      "phone": "718-555-0142",
                      "license": "NYC LMP 12345"
                    },
                    "customer": {
                      "name": "Dana"
                    },
                    "title": "Replace 50-gallon gas water heater",
                    "location": "Brooklyn, NY",
                    "currency": "USD",
                    "options": [
                      {
                        "name": "Standard",
                        "description": "Same-size tank, 6-year warranty",
                        "items": [
                          {
                            "description": "50-gallon gas water heater, 6-year warranty",
                            "unit_price": 1150
                          },
                          {
                            "description": "Labor: remove old heater, install, connect and test",
                            "unit_price": 450
                          },
                          {
                            "description": "Haul away the old heater",
                            "unit_price": 75
                          }
                        ]
                      },
                      {
                        "name": "Premium",
                        "description": "High-efficiency tank, 12-year warranty",
                        "items": [
                          {
                            "description": "50-gallon high-efficiency gas water heater, 12-year warranty",
                            "unit_price": 1850
                          },
                          {
                            "description": "Labor: remove old heater, install, connect and test",
                            "unit_price": 450
                          },
                          {
                            "description": "Haul away the old heater",
                            "unit_price": 75
                          }
                        ]
                      }
                    ],
                    "addons": [
                      {
                        "name": "Expansion tank",
                        "price": 95,
                        "description": "Protects the pipes from pressure build-up"
                      }
                    ],
                    "tax": {
                      "label": "Sales tax",
                      "rate_percent": 8.875
                    },
                    "notes": "About 4 hours of work. The water is off during the install.",
                    "source": {
                      "route": "api",
                      "client": "Example AI"
                    }
                  }
                },
                "painting": {
                  "summary": "One option with a quantity, a tax note and a deposit",
                  "value": {
                    "business": {
                      "name": "Brightline Painting"
                    },
                    "customer": {
                      "name": "Priya"
                    },
                    "title": "Paint living room and hallway",
                    "location": "Austin, TX",
                    "options": [
                      {
                        "name": "Walls and ceiling",
                        "items": [
                          {
                            "description": "Walls, two coats, per square foot",
                            "quantity": 620,
                            "unit_price": 2.1
                          },
                          {
                            "description": "Ceiling, one coat",
                            "unit_price": 180
                          },
                          {
                            "description": "Paint and supplies",
                            "unit_price": 240
                          }
                        ]
                      }
                    ],
                    "tax": {
                      "note": "Tax not included"
                    },
                    "deposit": {
                      "percent": 25,
                      "note": "Due when the job is booked"
                    },
                    "exclusions": "Moving large furniture and repairing water damage."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. The quote is live. A retry with the same Idempotency-Key and the same quote gets this same answer again.",
            "headers": {
              "Idempotent-Replayed": {
                "description": "true when this is the stored answer to a repeated Idempotency-Key.",
                "schema": {
                  "type": "string",
                  "const": "true"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateResult"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Invalid"
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/validate": {
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "validateQuote",
        "summary": "Check a quote without saving it",
        "description": "Takes exactly the body of /create, stores nothing and publishes nothing. Returns ok, the quote as it would be stored, the totals Workeey computes, and every problem as {field, problem, fix}.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checked. ok is false when errors lists problems.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidateResult"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/manage": {
      "get": {
        "tags": [
          "Quotes"
        ],
        "operationId": "getQuoteStatus",
        "summary": "Status, answers and versions",
        "description": "Everything the private page shows: the status, every version of the quote, every answer tied to the version it answered, how often the customer page was opened, the notification email state and the customer link. Browsers on other sites may call it (CORS).",
        "security": [
          {
            "manageToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "The quote as its owner sees it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ManageView"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Quotes"
        ],
        "operationId": "deleteQuotePersonalData",
        "summary": "Delete the personal details",
        "description": "Deletes the personal fields (names, contact details, messages) and keeps only anonymous job and price rows. The links stop being useful afterwards.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "deleted"
                  ],
                  "properties": {
                    "deleted": {
                      "const": true
                    }
                  }
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/manage/revise": {
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "reviseQuoteLink",
        "summary": "Publish a new version",
        "description": "Send the COMPLETE new quote, not only the changes (start from the last entry of versions in GET /manage). The customer link stays the same and shows the new version, marked as updated. Earlier answers stay tied to the version they answered: an approval of version 1 shows as \"approved v1, quote since revised\". A quote that is the same as the current version saves nothing and answers unchanged: true with the current version number.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved as a new version, or unchanged.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "version",
                    "unchanged",
                    "valid_until",
                    "totals"
                  ],
                  "properties": {
                    "version": {
                      "type": "integer",
                      "minimum": 1,
                      "description": "The version the customer link shows now: the new number, or the current one when unchanged is true."
                    },
                    "unchanged": {
                      "type": "boolean",
                      "description": "true when the quote sent is the same as the current version: nothing new was saved."
                    },
                    "valid_until": {
                      "type": "string",
                      "format": "date"
                    },
                    "totals": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OptionTotal"
                      }
                    }
                  }
                },
                "examples": {
                  "revised": {
                    "value": {
                      "version": 2,
                      "unchanged": false,
                      "valid_until": "2026-12-06",
                      "totals": [
                        {
                          "option": 0,
                          "name": "Standard",
                          "subtotal": 1750,
                          "tax": null,
                          "total": 1750
                        }
                      ]
                    }
                  },
                  "unchanged": {
                    "value": {
                      "version": 1,
                      "unchanged": true,
                      "valid_until": "2026-12-06",
                      "totals": [
                        {
                          "option": 0,
                          "name": "Standard",
                          "subtotal": 1600,
                          "tax": null,
                          "total": 1600
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "error disabled: Workeey switched this quote off.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "error cancelled (the quote was cancelled) or too_many_versions (it has as many versions as a quote can have). Make a new quote instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Invalid"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/manage/cancel": {
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "cancelQuoteLink",
        "summary": "Cancel the quote",
        "description": "The customer link then says the quote was cancelled and can no longer be approved. Answers and versions so far are kept. To change prices or options, revise instead.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "const": "cancelled"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/manage/accept-terms": {
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "acceptTerms",
        "summary": "Agree to the Terms (the private page only)",
        "description": "Records that the business agreed to Workeey's Terms and Privacy Policy, for the version it was shown. The private page calls it when the business ticks the box, the first time it opens the page. An AI or a script never calls it, and nothing agrees for a person: agreeing is their own act.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "version"
                ],
                "properties": {
                  "version": {
                    "type": "string",
                    "pattern": "^[0-9A-Za-z._-]{1,32}$",
                    "description": "The terms in force, as the private page showed them: terms.current from GET /manage.",
                    "examples": [
                      "2026-11-06"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Agreed. The same version again answers the same and keeps the first time.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "accepted",
                    "version",
                    "accepted_at"
                  ],
                  "properties": {
                    "accepted": {
                      "const": true
                    },
                    "version": {
                      "type": "string",
                      "description": "The terms version agreed to."
                    },
                    "accepted_at": {
                      "type": "string",
                      "format": "date-time",
                      "description": "When the business agreed."
                    }
                  }
                },
                "example": {
                  "accepted": true,
                  "version": "2026-11-06",
                  "accepted_at": "2026-11-06T15:04:05.000Z"
                }
              }
            }
          },
          "409": {
            "description": "error terms_changed: the terms in force are another version, named in version. Show those and send that version.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "terms_changed",
                  "message": "The terms have changed. Please read the current version.",
                  "version": "2026-11-06"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Invalid"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/manage/email": {
      "post": {
        "tags": [
          "Private link by email"
        ],
        "operationId": "setNotificationEmail",
        "summary": "Add an email for answers and recovery",
        "description": "Emails a confirmation link to the address. Only after it is confirmed is the address used: an email when the customer answers, and recovery of lost private links. If this same address is already confirmed for this quote, nothing is sent and the answer says so.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 120
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Done.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sent",
                    "verified"
                  ],
                  "properties": {
                    "sent": {
                      "type": "boolean",
                      "description": "true when a confirmation email went out now."
                    },
                    "verified": {
                      "type": "boolean",
                      "description": "true when this address was already confirmed for this quote (then nothing is sent)."
                    }
                  }
                },
                "examples": {
                  "sent": {
                    "value": {
                      "sent": true,
                      "verified": false
                    }
                  },
                  "alreadyConfirmed": {
                    "value": {
                      "sent": false,
                      "verified": true
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "The email address is missing or is not an email address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvalidError"
                },
                "example": {
                  "error": "invalid",
                  "message": "…",
                  "errors": [
                    {
                      "field": "email",
                      "code": "email",
                      "problem": "is not an email address",
                      "fix": "send {\"email\": \"you@example.com\"}"
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/recover": {
      "post": {
        "tags": [
          "Private link by email"
        ],
        "operationId": "recoverPrivateLinks",
        "summary": "Email me my private links",
        "description": "Always answers {sent: true}, whether or not the address is known, so nobody can learn which addresses have quotes. If confirmed quotes exist for it, their private links are emailed to it. Its daily limits are applied silently: past them, the answer is the same and nothing more is sent.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 120
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Sent"
                }
              }
            }
          },
          "422": {
            "description": "The email address is missing or is not an email address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvalidError"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/verify-email": {
      "post": {
        "tags": [
          "Private link by email"
        ],
        "operationId": "verifyEmail",
        "summary": "Confirm an email address",
        "description": "The confirmation email links to the page https://www.workeey.com/quote-links/email-confirmed/#t=<code>. The code is in the #fragment, which a browser never sends to a server, and that page POSTs it here. Only a POST confirms: a mail scanner that opens or pre-fetches the link confirms nothing, and GET or HEAD on this route answers 405. Sending the same code again answers the same 200. There is no redirect.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "t"
                ],
                "properties": {
                  "t": {
                    "type": "string",
                    "description": "The code after #t= in the confirmation link."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmed. Safe to repeat: the same code again gives the same answer.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "confirmed"
                  ],
                  "properties": {
                    "confirmed": {
                      "const": true
                    }
                  }
                }
              }
            }
          },
          "410": {
            "description": "The code is wrong, was replaced by a newer email, is more than 7 days old, or its quote was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid",
                  "message": "…"
                }
              }
            }
          },
          "422": {
            "description": "t is missing or is not a confirmation code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvalidError"
                },
                "example": {
                  "error": "invalid",
                  "message": "…",
                  "errors": [
                    {
                      "field": "t",
                      "code": "required",
                      "problem": "is required",
                      "fix": "send {\"t\": \"<the code after #t= in the confirmation link>\"}"
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/public/{customer_token}": {
      "get": {
        "tags": [
          "Customer page"
        ],
        "operationId": "getPublicQuote",
        "summary": "The quote as the customer sees it",
        "description": "Read-only and harmless: the latest version, its totals, the status and the latest answer, never the source claim. Opening the customer link in a browser is what counts as an open, not this call.",
        "parameters": [
          {
            "name": "customer_token",
            "in": "path",
            "required": true,
            "description": "The customer token: the last part of the customer link https://www.workeey.com/q/<customer_token>, 43 characters of base64url.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{43}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The public view.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicQuote"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/public/{customer_token}/open": {
      "post": {
        "tags": [
          "Customer page"
        ],
        "operationId": "recordCustomerOpen",
        "summary": "Count an open (customer page only)",
        "description": "Called by the customer page only. An AI or script working for the business must never call it: answering or opening for the customer would make the record false. The page sends it once after it loads; link-preview bots are not counted.",
        "parameters": [
          {
            "name": "customer_token",
            "in": "path",
            "required": true,
            "description": "The customer token: the last part of the customer link https://www.workeey.com/q/<customer_token>, 43 characters of base64url.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{43}$"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "creator": {
                    "type": "boolean",
                    "description": "true when the quote was made in this browser, so the open is not counted as the customer's. Other fields are ignored."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Counted."
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/public/{customer_token}/respond": {
      "post": {
        "tags": [
          "Customer page"
        ],
        "operationId": "customerRespond",
        "summary": "The customer's answer (customer page only)",
        "description": "Called by the customer page only. An AI or script working for the business must never call it: answering or opening for the customer would make the record false. 10 per customer link per hour.",
        "parameters": [
          {
            "name": "customer_token",
            "in": "path",
            "required": true,
            "description": "The customer token: the last part of the customer link https://www.workeey.com/q/<customer_token>, 43 characters of base64url.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{43}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RespondInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "saved",
                    "response"
                  ],
                  "properties": {
                    "saved": {
                      "const": true
                    },
                    "response": {
                      "$ref": "#/components/schemas/QuoteResponse"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "error stale_version: the quote was updated since the page loaded; version is the version it shows now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "stale_version",
                  "message": "…",
                  "version": 2
                }
              }
            }
          },
          "410": {
            "description": "error expired, cancelled, disabled (switched off by Workeey) or deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Invalid"
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "503": {
            "$ref": "#/components/responses/Paused"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/public/{customer_token}/report": {
      "post": {
        "tags": [
          "Customer page"
        ],
        "operationId": "reportQuote",
        "summary": "Report this page",
        "description": "For someone who thinks the page is a scam, the wrong business or offensive. One report per address per quote, 3 per address per day.",
        "parameters": [
          {
            "name": "customer_token",
            "in": "path",
            "required": true,
            "description": "The customer token: the last part of the customer link https://www.workeey.com/q/<customer_token>, 43 characters of base64url.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{43}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "reason"
                ],
                "properties": {
                  "reason": {
                    "type": "string",
                    "enum": [
                      "scam",
                      "wrong_business",
                      "offensive",
                      "other"
                    ]
                  },
                  "details": {
                    "type": "string",
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Received."
          },
          "429": {
            "$ref": "#/components/responses/TooMany"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "manageToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "The manage token returned by /create: 43 characters of base64url, also the part after # in the private link https://www.workeey.com/q/m/#<manage_token>. It works like a password."
      }
    },
    "responses": {
      "Invalid": {
        "description": "The quote has problems. Nothing was saved. Apply every fix and send again.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/InvalidError"
            },
            "example": {
              "error": "invalid",
              "message": "Some fields need fixing. Each item in \"errors\" names the field, the problem and the fix.",
              "errors": [
                {
                  "field": "options[0].items[2].unit_price",
                  "code": "number",
                  "problem": "must be a number ≥ 0",
                  "fix": "send a plain number like 1450 or \"1,450.00\", not \"about 1.5k\""
                }
              ]
            }
          }
        }
      },
      "TooMany": {
        "description": "Too many requests for now. Wait the Retry-After seconds, then try again.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/RateLimited"
            }
          }
        }
      },
      "Unavailable": {
        "description": "Paused for maintenance (error \"paused\"), or email is not available right now (error \"email_unavailable\").",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Paused": {
        "description": "Quote Links is paused for maintenance.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "paused",
              "message": "Quote Links is paused for maintenance."
            }
          }
        }
      },
      "Error": {
        "description": "Any other problem, as a short code and a plain sentence.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "QuoteInput": {
        "type": "object",
        "description": "A quote. The body of /create, /validate and /manage/revise, and the JSON inside a draft link. Unknown fields are ignored. Text is trimmed and may be any UTF-8 (right-to-left scripts, emoji and quotation marks are fine). Numbers should be plain numbers; numeric strings such as \"1,450.00\" are also accepted.",
        "required": [
          "business",
          "customer",
          "title",
          "options"
        ],
        "properties": {
          "business": {
            "$ref": "#/components/schemas/Business"
          },
          "customer": {
            "$ref": "#/components/schemas/Customer"
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "The job, in a few words.",
            "examples": [
              "Replace 50-gallon gas water heater"
            ]
          },
          "location": {
            "type": "string",
            "maxLength": 80,
            "description": "Where the job is: city and state only, never the customer's street address.",
            "examples": [
              "Brooklyn, NY"
            ]
          },
          "currency": {
            "type": "string",
            "pattern": "^[A-Z]{3}$",
            "default": "USD",
            "description": "Three-letter ISO 4217 code of a currency with cents (2 decimals), like USD, CAD or EUR. A currency without 2-decimal cents, such as JPY (none) or KWD (3), isn't supported yet: it is refused with an error on currency.",
            "examples": [
              "USD"
            ]
          },
          "options": {
            "type": "array",
            "minItems": 1,
            "maxItems": 4,
            "items": {
              "$ref": "#/components/schemas/Option"
            },
            "description": "The choices the customer picks between, each priced on its own. One option for a single-price quote."
          },
          "addons": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "$ref": "#/components/schemas/Addon"
            },
            "description": "Extras the customer can tick on top of the option they pick."
          },
          "tax": {
            "$ref": "#/components/schemas/Tax"
          },
          "deposit": {
            "$ref": "#/components/schemas/Deposit"
          },
          "notes": {
            "type": "string",
            "maxLength": 2000,
            "description": "Anything else the customer should know. No links, email addresses or payment details (Zelle, Venmo, Cash App, PayPal, wire, ACH, routing or account numbers, crypto, $cashtags)."
          },
          "exclusions": {
            "type": "string",
            "maxLength": 2000,
            "description": "What the price does not include."
          },
          "terms": {
            "type": "string",
            "maxLength": 2000,
            "description": "The business's terms, such as warranty or cancellation. No links, email addresses or payment details (Zelle, Venmo, Cash App, PayPal, wire, ACH, routing or account numbers, crypto, $cashtags)."
          },
          "valid_until": {
            "type": "string",
            "format": "date",
            "description": "YYYY-MM-DD. Default 30 days from today; at most 365 days ahead."
          },
          "source": {
            "$ref": "#/components/schemas/Source"
          },
          "terms_accepted": {
            "type": "boolean",
            "description": "Sent only by the Workeey editor, true once the person ticked \"I agree to the Terms and the Privacy Policy\". An AI or a script never sends it: it cannot agree for a person. A quote made any other way is agreed to by the business itself, the first time it opens its private page."
          }
        }
      },
      "Business": {
        "type": "object",
        "description": "The business sending the quote. Shown to the customer as provided by the creator, not verified by Workeey.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "Business name.",
            "examples": [
              "Rivera Plumbing"
            ]
          },
          "phone": {
            "type": "string",
            "maxLength": 30,
            "description": "Optional phone number.",
            "examples": [
              "718-555-0142"
            ]
          },
          "email": {
            "type": "string",
            "maxLength": 120,
            "description": "Optional email: the only field where an email address is allowed."
          },
          "address": {
            "type": "string",
            "maxLength": 160,
            "description": "Optional business address."
          },
          "license": {
            "type": "string",
            "maxLength": 60,
            "description": "Optional license number.",
            "examples": [
              "NYC LMP 12345"
            ]
          }
        }
      },
      "Customer": {
        "type": "object",
        "description": "A display name only. Never the customer's phone, email or address.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "The name the customer sees, such as a first name.",
            "examples": [
              "Dana"
            ]
          }
        }
      },
      "Option": {
        "type": "object",
        "required": [
          "name",
          "items"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40,
            "description": "Option name.",
            "examples": [
              "Standard"
            ]
          },
          "description": {
            "type": "string",
            "maxLength": 500,
            "description": "What this option includes or how it differs."
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": {
              "$ref": "#/components/schemas/LineItem"
            },
            "description": "Subtotal = the sum of quantity × unit_price."
          }
        }
      },
      "LineItem": {
        "type": "object",
        "required": [
          "description",
          "unit_price"
        ],
        "properties": {
          "description": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "What the line is for."
          },
          "quantity": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 100000,
            "default": 1
          },
          "unit_price": {
            "type": "number",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Price of one unit, at most 2 decimals: 1450, not \"about 1.5k\"."
          }
        }
      },
      "Addon": {
        "type": "object",
        "required": [
          "name",
          "price"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "Add-on name.",
            "examples": [
              "Expansion tank"
            ]
          },
          "price": {
            "type": "number",
            "minimum": 0
          },
          "description": {
            "type": "string",
            "maxLength": 300,
            "description": "A short explanation."
          }
        }
      },
      "Tax": {
        "description": "Tax exactly as supplied; Workeey never decides tax rules. ONE of: a rate (applied to the chosen option plus the chosen add-ons), a fixed amount, or a note such as \"Tax not included\".",
        "oneOf": [
          {
            "title": "Tax rate",
            "type": "object",
            "required": [
              "rate_percent"
            ],
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 40,
                "description": "What the tax is called.",
                "examples": [
                  "Sales tax"
                ]
              },
              "rate_percent": {
                "type": "number",
                "minimum": 0,
                "maximum": 30,
                "description": "8.875 means 8.875%."
              }
            }
          },
          {
            "title": "Tax amount",
            "type": "object",
            "required": [
              "amount"
            ],
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 40,
                "description": "What the tax is called."
              },
              "amount": {
                "type": "number",
                "minimum": 0
              }
            }
          },
          {
            "title": "Tax note",
            "type": "object",
            "required": [
              "note"
            ],
            "properties": {
              "note": {
                "type": "string",
                "maxLength": 80,
                "description": "Instead of a number.",
                "examples": [
                  "Tax not included"
                ]
              }
            }
          }
        ]
      },
      "Deposit": {
        "type": "object",
        "description": "Deposit wording shown on the quote: percent OR amount, and an optional note. Text only; Workeey takes no payments.",
        "properties": {
          "percent": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "amount": {
            "type": "number",
            "minimum": 0
          },
          "note": {
            "type": "string",
            "maxLength": 200,
            "description": "When the deposit is due. No links, email addresses or payment details (Zelle, Venmo, Cash App, PayPal, wire, ACH, routing or account numbers, crypto, $cashtags)."
          }
        },
        "not": {
          "required": [
            "percent",
            "amount"
          ]
        }
      },
      "Source": {
        "type": "object",
        "description": "Where the quote came from. client is an unverified claim; neither is shown to the customer.",
        "properties": {
          "route": {
            "type": "string",
            "description": "draft_link, paste, form, api or mcp: send api when you call the API. A missing or other route is recorded as unknown, never refused.",
            "examples": [
              "api"
            ]
          },
          "client": {
            "type": "string",
            "maxLength": 60,
            "description": "The name of the app or AI making the quote.",
            "examples": [
              "ChatGPT"
            ]
          }
        }
      },
      "OptionTotal": {
        "type": "object",
        "description": "One entry per option, in the same order as options. Add-ons are not included: the customer picks them.",
        "required": [
          "option",
          "name",
          "subtotal",
          "tax",
          "total"
        ],
        "properties": {
          "option": {
            "type": "integer",
            "minimum": 0,
            "description": "The option, by its index from 0."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The option's name; null only in a /validate answer, for an option that has an error."
          },
          "subtotal": {
            "type": [
              "number",
              "null"
            ],
            "description": "null only in a /validate answer, for an option that has an error."
          },
          "tax": {
            "type": [
              "number",
              "null"
            ],
            "description": "null when the quote has no tax figure (a tax note, or no tax), or in a /validate answer when the tax has an error."
          },
          "total": {
            "type": [
              "number",
              "null"
            ],
            "description": "null only in a /validate answer, for an option that has an error."
          }
        }
      },
      "CreateResult": {
        "type": "object",
        "required": [
          "id",
          "version",
          "customer_url",
          "manage_url",
          "manage_token",
          "valid_until",
          "totals"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "version": {
            "const": 1
          },
          "customer_url": {
            "type": "string",
            "format": "uri",
            "description": "https://www.workeey.com/q/<customer_token>: give it to the business to send to its customer.",
            "examples": [
              "https://www.workeey.com/q/2Fq0yWmD3k7xLrB8nVtP1aHsZcJ5eGuQ9oXiN4bKdYw"
            ]
          },
          "manage_url": {
            "type": "string",
            "format": "uri",
            "description": "https://www.workeey.com/q/m/#<manage_token>: the private link. Only for the business; it works like a password."
          },
          "manage_token": {
            "type": "string",
            "description": "The token for /manage calls (Authorization: Bearer)."
          },
          "valid_until": {
            "type": "string",
            "format": "date"
          },
          "totals": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OptionTotal"
            }
          }
        }
      },
      "ValidateResult": {
        "type": "object",
        "required": [
          "ok",
          "quote",
          "totals",
          "errors"
        ],
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "quote": {
            "description": "The quote as it would be stored (normalized), with source as it would be recorded (route unknown when missing or not one of the five); null when the body could not be read as a quote. When ok is false, a field with a problem keeps the value that was sent, so the quote may not fit QuoteInput.",
            "anyOf": [
              {
                "$ref": "#/components/schemas/QuoteInput"
              },
              {
                "type": "object"
              },
              {
                "type": "null"
              }
            ]
          },
          "totals": {
            "anyOf": [
              {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/OptionTotal"
                }
              },
              {
                "type": "null"
              }
            ]
          },
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            }
          }
        }
      },
      "FieldError": {
        "type": "object",
        "required": [
          "field",
          "problem",
          "fix"
        ],
        "properties": {
          "field": {
            "type": "string",
            "description": "The path of the field, like options[0].items[2].unit_price."
          },
          "code": {
            "type": "string",
            "description": "A short word for the kind of problem, such as required, link, email or payment."
          },
          "problem": {
            "type": "string"
          },
          "fix": {
            "type": "string",
            "description": "What to change, in plain words."
          }
        }
      },
      "InvalidError": {
        "type": "object",
        "required": [
          "error",
          "errors"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "invalid",
              "idempotency_conflict"
            ],
            "description": "invalid: fix the fields listed. idempotency_conflict (only /create): this Idempotency-Key was already used for a different quote; use a new random UUID."
          },
          "message": {
            "type": "string",
            "description": "A plain sentence."
          },
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            }
          }
        }
      },
      "RateLimited": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "const": "rate_limited"
          },
          "message": {
            "type": "string",
            "description": "A plain sentence."
          },
          "retry_after": {
            "type": "integer",
            "minimum": 1,
            "description": "Seconds to wait, the same as the Retry-After header."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "A short code: unauthorized (401, no or malformed manage token), not_found (404), deleted (410), disabled (403 on revise and email, 410 on respond), cancelled (409 on revise, 410 on respond), expired (410 on respond), stale_version (409 on respond), terms_changed (409 on accept-terms), too_many_versions (409), too_many_answers (429 on respond, final), rate_limited (429), invalid_json (400), too_large (413), method_not_allowed (405), paused (503), not_configured (503), email_unavailable (503) or internal (500)."
          },
          "message": {
            "type": "string",
            "description": "A plain sentence."
          },
          "reason": {
            "type": "string",
            "enum": [
              "owner",
              "retention"
            ],
            "description": "With deleted: the owner deleted the details, or they were deleted after the retention period."
          },
          "version": {
            "type": [
              "integer",
              "string"
            ],
            "minimum": 1,
            "description": "With stale_version: the quote version the customer link shows now (a number). With terms_changed: the terms version in force (a string such as 2026-11-06)."
          },
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FieldError"
            },
            "description": "With invalid_json and too_large: what to fix."
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "With not_found for an address that is not an endpoint: this documentation."
          }
        }
      },
      "Sent": {
        "type": "object",
        "required": [
          "sent"
        ],
        "properties": {
          "sent": {
            "const": true
          }
        }
      },
      "Status": {
        "type": "string",
        "enum": [
          "open",
          "approved",
          "declined",
          "changes_requested",
          "expired",
          "cancelled",
          "disabled"
        ]
      },
      "QuoteResponse": {
        "type": "object",
        "description": "One answer from the customer, tied to the version they saw. The public view (GET /public/{customer_token}) leaves out message.",
        "required": [
          "action",
          "option",
          "addons",
          "name",
          "amount",
          "at",
          "version",
          "option_name",
          "addon_names"
        ],
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "approve",
              "decline",
              "changes"
            ]
          },
          "option": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "The option picked, by index from 0; null when none was picked (a decline or a change request may have none)."
          },
          "option_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "That option's name in the version answered; null when no option was picked."
          },
          "addons": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 0
            },
            "description": "The add-ons ticked, by index from 0."
          },
          "addon_names": {
            "type": "array",
            "items": {
              "type": [
                "string",
                "null"
              ]
            },
            "description": "Their names in the version answered, in the same order as addons."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The name the customer typed; null once the personal details are deleted."
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "description": "For changes: what the customer asked for (links removed); null when there is none or once the personal details are deleted."
          },
          "amount": {
            "type": [
              "number",
              "null"
            ],
            "description": "The total for the option and add-ons picked, tax included as supplied; null when no option was picked."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "Server time."
          },
          "version": {
            "type": "integer",
            "minimum": 1,
            "description": "The version that was answered."
          }
        }
      },
      "PublicQuote": {
        "type": "object",
        "required": [
          "status",
          "version",
          "updated_at",
          "created_at",
          "valid_until",
          "quote",
          "totals",
          "response"
        ],
        "properties": {
          "status": {
            "$ref": "#/components/schemas/Status"
          },
          "version": {
            "type": "integer",
            "minimum": 1
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When this version was published."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "valid_until": {
            "type": "string",
            "format": "date"
          },
          "quote": {
            "description": "The latest version, normalized, without source; null when status is disabled.",
            "anyOf": [
              {
                "$ref": "#/components/schemas/QuoteInput"
              },
              {
                "type": "null"
              }
            ]
          },
          "totals": {
            "description": "null when status is disabled.",
            "anyOf": [
              {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/OptionTotal"
                }
              },
              {
                "type": "null"
              }
            ]
          },
          "response": {
            "description": "The latest answer, without message; null when there is none or status is disabled.",
            "anyOf": [
              {
                "$ref": "#/components/schemas/QuoteResponse"
              },
              {
                "type": "null"
              }
            ]
          },
          "message": {
            "type": "string",
            "description": "Only when status is disabled: a plain sentence saying the quote was switched off."
          }
        }
      },
      "ManageView": {
        "type": "object",
        "required": [
          "id",
          "status",
          "version",
          "valid_until",
          "created_at",
          "updated_at",
          "customer_url",
          "versions",
          "responses",
          "opens",
          "email",
          "terms"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/Status"
          },
          "version": {
            "type": "integer",
            "minimum": 1,
            "description": "The version the customer link shows."
          },
          "valid_until": {
            "type": "string",
            "format": "date"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "customer_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The customer link, https://www.workeey.com/q/<customer_token>; null in the rare case it cannot be rebuilt (the private link still works)."
          },
          "versions": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "version",
                "created_at",
                "quote",
                "totals"
              ],
              "properties": {
                "version": {
                  "type": "integer",
                  "minimum": 1
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "quote": {
                  "$ref": "#/components/schemas/QuoteInput"
                },
                "totals": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OptionTotal"
                  }
                }
              }
            }
          },
          "responses": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/QuoteResponse"
            },
            "description": "Every answer, oldest first."
          },
          "opens": {
            "type": "object",
            "required": [
              "count",
              "last_at"
            ],
            "description": "Opens of the customer page by people, not link-preview robots and not the creator.",
            "properties": {
              "count": {
                "type": "integer",
                "minimum": 0
              },
              "last_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              }
            }
          },
          "terms": {
            "type": "object",
            "required": [
              "version",
              "accepted_at",
              "current"
            ],
            "description": "Workeey's Terms and Privacy Policy for this quote. A quote made in the Workeey editor is agreed to when it is created; one made any other way when the business first opens its private page, which asks while accepted_at is null.",
            "properties": {
              "version": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The terms version on the quote: stamped when it was made, and the version agreed to once accepted_at is set.",
                "examples": [
                  "2026-11-06"
                ]
              },
              "accepted_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "When the business agreed; null until it does."
              },
              "current": {
                "type": "string",
                "description": "The terms in force now: the version the private page shows and sends to POST /manage/accept-terms.",
                "examples": [
                  "2026-11-06"
                ]
              }
            }
          },
          "email": {
            "type": "object",
            "required": [
              "set",
              "verified"
            ],
            "description": "The notification email: whether one was added, and whether it was confirmed.",
            "properties": {
              "set": {
                "type": "boolean"
              },
              "verified": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "RespondInput": {
        "type": "object",
        "required": [
          "version",
          "action",
          "name"
        ],
        "properties": {
          "version": {
            "type": "integer",
            "minimum": 1,
            "description": "The version the customer is looking at."
          },
          "action": {
            "type": "string",
            "enum": [
              "approve",
              "decline",
              "changes"
            ]
          },
          "option": {
            "type": "integer",
            "minimum": 0,
            "description": "The option picked, by index from 0. Required for approve."
          },
          "addons": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 0
            }
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "The name the customer types."
          },
          "message": {
            "type": "string",
            "maxLength": 1000,
            "description": "For changes: what to change. Links are removed."
          }
        }
      }
    }
  }
}
