{
  "openapi": "3.1.0",
  "info": {
    "title": "Rough Cut Manufacturing customer API",
    "version": "1.0.0",
    "summary": "Request a quote, follow a job on the buyer status board, and read an invoice from its portal link.",
    "description": "The public endpoints behind roughcutmfg.com's quote page, buyer status board (/status) and invoice portal (/portal). Every call is a JSON POST. There are no API keys: a status board or invoice is read with the private link token the shop emailed to the buyer, sent in the request body (never in the URL). Quote requests and status-link requests also need a Cloudflare Turnstile token solved on roughcutmfg.com, so an automated client should send the person to https://roughcutmfg.com/quote or https://roughcutmfg.com/status to complete those. Every endpoint is rate limited per client IP and answers 429 when over the limit.",
    "termsOfService": "https://roughcutmfg.com/terms",
    "contact": {
      "name": "Rough Cut Manufacturing",
      "url": "https://roughcutmfg.com/contact",
      "email": "General@roughcutmfg.com"
    }
  },
  "externalDocs": {
    "description": "API for software and AI agents",
    "url": "https://roughcutmfg.com/developers/"
  },
  "servers": [{ "url": "https://roughcutmfg.com/api" }],
  "security": [],
  "tags": [
    { "name": "Quotes", "description": "Requests for a quote from the shop." },
    {
      "name": "Status board",
      "description": "A buyer's board of their jobs and the stage each one is at."
    },
    { "name": "Invoices", "description": "An invoice opened from its portal link." }
  ],
  "paths": {
    "/submit-proposal": {
      "post": {
        "tags": ["Quotes"],
        "operationId": "requestQuote",
        "summary": "Request a quote",
        "description": "Sends a request for a quote to the shop's quoting board and emails the requester a confirmation. Needs a Cloudflare Turnstile token solved on https://roughcutmfg.com/quote, and files (drawings, DXF, STEP, STL) are attached through that page, so an automated client should send the person there. The quote page also sends store-cart and 3D-print-order fields that this description leaves out.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/QuoteRequest" },
              "example": {
                "contactName": "Dana Smith",
                "email": "dana@example.com",
                "phone": "(555) 555-0100",
                "description": "40 enclosed foam work pads, 24 x 36 x 2 in, gray nylon.",
                "heardAbout": "ai_assistant",
                "turnstileToken": "<token from the quote page>"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The request reached the shop.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/QuoteReceived" }
              }
            }
          },
          "400": {
            "description": "A required field is missing, the email is not valid, or the Turnstile check failed.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/job-status": {
      "post": {
        "tags": ["Status board"],
        "operationId": "jobStatusBoard",
        "summary": "Read a buyer's status board",
        "description": "One endpoint, chosen by `action`. A board link is emailed to the buyer as https://roughcutmfg.com/status?link=<token>; the token is the `link` parameter (a link sent before 2026-10-21 carries it after `#` instead).\n\n- `peek`: what a link is, without using it up.\n- `open`: uses the emailed link up (it works once) and answers with the board and a device token that reads the board from then on. Call it only when the person asks to open their board; send your own `device` token to be able to retry a lost answer.\n- `view`: the board, read with an opened link's device token or a legacy link.\n- `signout`: deletes that device token.\n- `shop`: the shop's name, contacts and logo; needs no token.\n\nThe board shows each job's stage, never prices, rates, hours or internal notes. The Content-Type must be application/json.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/JobStatusRequest" },
              "example": {
                "action": "view",
                "token": "EXAMPLE-device-token-from-an-opened-link-000"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "peek: { ok, state, account, name }. open: { ok, payload, token }. view: { ok, payload }. signout: { ok: true } for every well-formed request. shop: { ok, shop } (shop is null when it cannot be read).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/JobStatusResponse" }
              }
            }
          },
          "400": {
            "description": "Bad JSON, an unknown action, or a malformed device token.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "404": {
            "description": "The link is invalid, has expired, has been used, or no longer works.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "415": { "$ref": "#/components/responses/UnsupportedMediaType" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" },
          "503": {
            "description": "The board changed while it was being read. Nothing was used up; try again in a moment.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          }
        }
      }
    },
    "/job-status-signin": {
      "post": {
        "tags": ["Status board"],
        "operationId": "emailStatusBoardLink",
        "summary": "Email a buyer a fresh board link",
        "description": "Emails a new status board link to the address if it belongs to a buyer account. The answer is the same 200 { ok: true } whether or not the address has an account, and it arrives after a fixed delay of about two seconds. Needs a Cloudflare Turnstile token solved on https://roughcutmfg.com/status, so an automated client should send the person there. The Content-Type must be application/json.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/StatusSignInRequest" },
              "example": {
                "email": "dana@example.com",
                "turnstileToken": "<token from the status page>"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted. Says nothing about whether the address has an account.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Ok" } }
            }
          },
          "400": {
            "description": "Bad JSON, a malformed email address, or the Turnstile check failed.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "415": { "$ref": "#/components/responses/UnsupportedMediaType" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/portal-invoice": {
      "post": {
        "tags": ["Invoices"],
        "operationId": "readPortalInvoice",
        "summary": "Read an invoice from its portal link",
        "description": "Reads the invoice behind a portal link the shop emailed, https://roughcutmfg.com/portal/<token>. Read only: it changes nothing.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/TokenRequest" },
              "example": { "token": "EXAMPLE-token-from-the-emailed-portal-link-00" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The invoice.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PortalInvoiceResponse" }
              }
            }
          },
          "400": {
            "description": "The body is not valid JSON.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "404": {
            "description": "The link is invalid, has expired, or was revoked.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "LinkToken": {
        "type": "string",
        "description": "An opaque link or device token: 40 to 64 base64url characters.",
        "pattern": "^[A-Za-z0-9_-]{40,64}$"
      },
      "QuoteRequest": {
        "type": "object",
        "required": ["contactName", "email", "phone", "description", "turnstileToken"],
        "properties": {
          "contactName": { "type": "string", "maxLength": 200 },
          "email": { "type": "string", "format": "email", "maxLength": 320 },
          "phone": { "type": "string", "maxLength": 50 },
          "description": {
            "type": "string",
            "maxLength": 20000,
            "description": "What to make: the part, size, material and quantity."
          },
          "heardAbout": {
            "type": "string",
            "description": "How the requester heard about the shop.",
            "enum": ["ai_assistant", "search", "referral", "customer", "directory", "other"]
          },
          "submissionId": {
            "type": "string",
            "description": "An id the client makes for this request, stored with it."
          },
          "turnstileToken": {
            "type": "string",
            "description": "A Cloudflare Turnstile token solved on https://roughcutmfg.com/quote."
          }
        }
      },
      "QuoteReceived": {
        "type": "object",
        "required": ["ok", "jobCode"],
        "properties": {
          "ok": { "const": true },
          "jobCode": {
            "type": "integer",
            "description": "The number the shop's quoting board gave the request."
          },
          "emailQueue": {
            "type": "object",
            "description": "Whether the shop's and the requester's emails were queued."
          },
          "warnings": { "type": "array", "items": { "type": "string" } }
        }
      },
      "JobStatusRequest": {
        "type": "object",
        "required": ["action"],
        "properties": {
          "action": { "type": "string", "enum": ["peek", "open", "view", "signout", "shop"] },
          "token": {
            "$ref": "#/components/schemas/LinkToken",
            "description": "The emailed link's token (peek, open), or the device token (view, signout). Not needed for shop."
          },
          "device": {
            "$ref": "#/components/schemas/LinkToken",
            "description": "open only: a device token the client made, so a lost answer can be retried."
          }
        }
      },
      "JobStatusResponse": {
        "type": "object",
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean" },
          "state": {
            "type": "string",
            "enum": ["unopened", "legacy"],
            "description": "peek: an account link not yet opened, or a legacy link that opens as it is."
          },
          "account": {
            "type": "string",
            "description": "peek: an opaque key for the board the link opens."
          },
          "name": { "type": "string", "description": "peek: the account's name." },
          "token": {
            "$ref": "#/components/schemas/LinkToken",
            "description": "open: the device token that reads the board from now on."
          },
          "payload": { "$ref": "#/components/schemas/StatusBoard" },
          "shop": {
            "description": "shop: the shop's name, contacts and logo, or null when they cannot be read.",
            "oneOf": [{ "$ref": "#/components/schemas/ShopInfo" }, { "type": "null" }]
          }
        }
      },
      "StatusBoard": {
        "type": "object",
        "description": "open and view: the buyer's board.",
        "properties": {
          "label": { "type": "string", "description": "The account's name." },
          "account": { "type": "string" },
          "email": {
            "type": ["string", "null"],
            "description": "The account's email, masked."
          },
          "expiresAt": { "type": ["string", "null"], "format": "date-time" },
          "generatedAt": { "type": "string", "format": "date-time" },
          "shopToday": {
            "type": "string",
            "format": "date",
            "description": "The shop's calendar day (America/Los_Angeles) when the board was built."
          },
          "shop": {
            "type": "object",
            "properties": {
              "name": { "type": "string" },
              "email": { "type": "string" },
              "phone": { "type": "string" }
            }
          },
          "jobs": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/StatusBoardJob" }
          }
        }
      },
      "StatusBoardJob": {
        "type": "object",
        "properties": {
          "jobCode": { "type": "integer" },
          "name": { "type": "string" },
          "partNumber": { "type": ["string", "null"] },
          "po": { "type": ["string", "null"] },
          "qty": { "type": ["string", "null"] },
          "status": {
            "type": "string",
            "description": "The job's status, which the board shows as a stage (RFQ received through Paid)."
          },
          "isRush": { "type": "boolean" },
          "dueDate": { "type": ["string", "null"], "format": "date" },
          "statusChangedAt": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When the job last changed status."
          },
          "invoiced": { "type": "boolean", "description": "The invoice has gone out." }
        }
      },
      "ShopInfo": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "email": { "type": "string" },
          "phone": { "type": "string" },
          "logo": { "type": ["string", "null"], "description": "A raster data URL." }
        }
      },
      "StatusSignInRequest": {
        "type": "object",
        "required": ["email"],
        "properties": {
          "email": { "type": "string", "format": "email" },
          "turnstileToken": {
            "type": "string",
            "description": "A Cloudflare Turnstile token solved on https://roughcutmfg.com/status."
          }
        }
      },
      "TokenRequest": {
        "type": "object",
        "required": ["token"],
        "properties": { "token": { "$ref": "#/components/schemas/LinkToken" } }
      },
      "PortalInvoiceResponse": {
        "type": "object",
        "required": ["ok", "payload"],
        "properties": {
          "ok": { "const": true },
          "payload": {
            "type": "object",
            "description": "The invoice header and lines, the customer's name, the balance due and the statement."
          },
          "branding": {
            "type": ["object", "null"],
            "description": "The shop's name, contacts, logo (a raster data URL, or empty) and invoice template, as every emailed invoice prints them. Null when they could not be read.",
            "properties": {
              "companyName": { "type": "string" },
              "companyAddress": { "type": "string" },
              "companyPhone": { "type": "string" },
              "companyEmail": { "type": "string" },
              "logoDataUrl": { "type": "string" },
              "documentTemplate": { "type": ["object", "null"] }
            }
          }
        }
      },
      "Ok": {
        "type": "object",
        "required": ["ok"],
        "properties": { "ok": { "const": true } }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "ok": { "const": false },
          "error": { "type": "string", "description": "What went wrong, in words." },
          "retry": {
            "type": "boolean",
            "description": "True when the same request may simply be sent again."
          }
        }
      }
    },
    "responses": {
      "MethodNotAllowed": {
        "description": "Only POST is accepted.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "UnsupportedMediaType": {
        "description": "The Content-Type is not application/json.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "TooManyRequests": {
        "description": "Too many requests from this IP address. Try again shortly.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "ServerError": {
        "description": "Something failed on the server.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
