# Établi API reference

> Generated from the live OpenAPI document, https://api.etabli.io/openapi.json (OpenAPI 3.1, version 0.1.0). New here? Start with the quickstart: https://api.etabli.io/docs.md. For agents: https://etabli.io/llms.txt.

Order physical work in plain English. Post a project (or a single tender), answer a few questions, publish it to workshops, award a contract per leg, then place one order per unit of work. Établi finds the workshops, including ones not on Établi yet.

- Base URL: https://api.etabli.io. JSON in and out, camelCase fields.
- Auth: each endpoint says which key it takes, as `Authorization: Bearer …` ([Authentication](#authentication)): an API key, a draft's project key or a claimed project's owner token. Endpoints marked none need no key, and `POST /v1/projects` works with or without one; keyless calls that create something are rate-limited per IP address.
- Money is integer US cents (`buyerUnitPriceCents: 431` is $4.31). IDs are prefixed (`prj_`, `tnd_`, `ctr_`, `ord_`…).
- Errors: `{"error": {"code", "message", "details"}}` with the HTTP status (400 `validation`, 401 `unauthorized`, 403 `forbidden`, 404 `not_found`, 409 conflicts, 422 `policy_violation`, 429 `rate_limited` with `Retry-After`, 503 `spec_engine_unavailable`).
- Without a key: `POST https://api.etabli.io/v1/quotes/preview` plans a request, and `POST https://api.etabli.io/v1/projects` drafts it for the person to claim on https://etabli.io/claim: show them `project.next.message` and keep `projectKey`. Ask the person before sending their name and email anywhere.

## Contents

- [Authentication](#authentication): the keys the endpoints take
- [Quotes](#quotes): `POST /v1/quotes/preview`, `POST /v1/quote-requests`
- [Projects](#projects): `GET /v1/projects`, `POST /v1/projects`, `GET /v1/projects/{id}`, `DELETE /v1/projects/{id}`, `POST /v1/projects/{id}/claim-codes`, `GET /v1/projects/{id}/events`, `POST /v1/projects/{id}/agent-key/revoke`, `POST /v1/projects/{id}/publish`, `GET /v1/projects/{id}/offers`, `POST /v1/projects/{id}/award`, `GET /v1/claims/{code}`, `POST /v1/claims/{code}`
- [Tenders](#tenders): `GET /v1/tenders`, `POST /v1/tenders`, `GET /v1/tenders/{id}`, `POST /v1/tenders/{id}/answers`, `POST /v1/tenders/{id}/publish`, `POST /v1/tenders/{id}/cancel`, `GET /v1/tenders/{id}/offers`, `POST /v1/tenders/{id}/award`, `GET /v1/tenders/{id}/clarifications`, `POST /v1/clarifications/{id}/answer`
- [Contracts](#contracts): `GET /v1/contracts`, `GET /v1/contracts/{id}`, `GET /v1/contracts/{id}/statement`
- [Orders](#orders): `POST /v1/contracts/{id}/orders`, `GET /v1/orders`, `GET /v1/orders/{id}`, `POST /v1/orders/{id}/accept`, `POST /v1/orders/{id}/proofs`, `POST /v1/orders/{id}/complete`, `POST /v1/orders/{id}/cancel`
- [Accounts](#accounts): `GET /v1/me`
- [Support](#support): `POST /v1/support-requests`
- [Workshops](#workshops): `GET /v1/invitations`, `PUT /v1/tenders/{id}/bid`, `DELETE /v1/tenders/{id}/bid`, `POST /v1/tenders/{id}/clarifications`, `POST /v1/workshop-applications`
- [Bid links](#bid-links): `GET /v1/bid-links/{token}`, `POST /v1/bid-links/{token}/bid`, `POST /v1/bid-links/{token}/questions`, `POST /v1/bid-links/{token}/decline`
- [System](#system): `GET /`, `GET /health`, `POST /v1/waitlist`
- [Operators](#operators): `GET /v1/workshops`, `POST /v1/workshops`, `GET /v1/workshop-applications`, `POST /v1/workshop-applications/{id}/approve`, `POST /v1/workshop-applications/{id}/decline`, `GET /v1/quote-requests`, `POST /v1/quote-requests/{id}/project`, `GET /v1/support-requests`, `POST /v1/support-requests/{id}/handled`, `GET /v1/admin/overview`, `GET /v1/admin/activity`, `GET /v1/admin/projects`, `GET /v1/admin/projects/{id}`, `GET /v1/admin/agents`
- [Sourcing](#sourcing): `GET /v1/tenders/{id}/matches`, `POST /v1/tenders/{id}/sourcing`, `GET /v1/prospects`, `POST /v1/prospects`, `PATCH /v1/prospects/{id}`
- [Outreach](#outreach): `GET /v1/outreach`, `PATCH /v1/outreach/{id}`, `POST /v1/outreach/{id}/approve`, `POST /v1/outreach/{id}/sent`, `POST /v1/outreach/{id}/reject`, `GET /v1/outreach/{id}/replies`, `POST /v1/outreach/{id}/replies`, `GET /v1/outreach-channels`, `POST /v1/outreach-channels`, `PATCH /v1/outreach-channels/{id}`
- [Schemas](#schemas): the objects the endpoints take and return

## Authentication

Each endpoint says which of these keys it takes. Endpoints marked none need no key; where a key is optional, the endpoint's description says what it changes.

- `bearerAuth`, sent as `Authorization: Bearer …`: An API key, `etb_live_…` (`etb_test_…` outside production), for a buyer's, a workshop's or an operator's account
- `projectKey`, sent as `Authorization: Bearer …`: A project key, `etb_live_prj_…`, from a draft made without a key: reads that one project, answers its questions, mints claim codes
- `ownerToken`, sent as `Authorization: Bearer …`: An owner token, `etb_live_own_…`, from claiming a project (in the status page's link): reads it, its offers and events, reads and answers workshops' questions, revokes the agent's key

## Quotes

### POST /v1/quotes/preview

**Preview a quote (no API key needed).** Plans a plain-English request into the legs Établi would put out to bid (one workshop per leg; most jobs are one leg), each with its spec and the questions still open (with suggested answers). Établi adds no price estimate of its own: prices come from workshops' bids once the request is put out to bid. Nothing goes to workshops; the plan is kept for a day so a request made from it is instant. Send answers back with each question's `leg`, `field` and `text` to refine it.

Auth: none (no API key).

Request body (application/json, [QuotePreviewRequest](#quotepreviewrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–2,000 characters |
| `answers` | array of [Answer](#answer) | no | up to 20 items; default `[]` |

Responses:

- `200` The plan: [PlanPreview](#planpreview)
- `400` Invalid request: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)
- `503` The spec engine is unavailable; retry shortly: [Error](#error)

### POST /v1/quote-requests

**Ask for firm quotes (no API key needed).** Sends the request, with any answers, to Établi. Firm prices from workshops follow by email.

Auth: none (no API key).

Request body (application/json, [QuoteRequestRequest](#quoterequestrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–2,000 characters |
| `answers` | array of [Answer](#answer) | no | up to 20 items; default `[]` |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | format email |
| `company` | string or null | no | up to 120 characters; default `null` |
| `note` | string or null | no | up to 1,000 characters; default `null` |

Responses:

- `201` Request received: an object with `ok` (boolean), `id` (string), `plan` ([PlanPreview](#planpreview))
- `400` Invalid request: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

## Projects

### GET /v1/projects

**List your projects, newest first.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Projects: array of [Project](#project)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### POST /v1/projects

**Plan a request into legs (with a buyer key), or draft one for a person to claim.** Breaks the request into legs (one per kind of workshop it needs: a pot factory feeding a nursery is two), each a tender with its own spec and questions.

With a buyer key: the project is yours. Answer each leg's questions with POST /v1/tenders/{tenderId}/answers, then publish the project.

Without a key: a draft that belongs to nobody until a person claims it on etabli.io (7 days). The response carries a project key (shown once: keep it) and a claim link with a short code: show the person `project.next.message` verbatim. Nothing reaches a workshop and nothing is charged until they claim it. Send `Idempotency-Key` to make retries safe. 10 drafts an hour and 30 a day per address.

Auth: optional: an API key, as `Authorization: Bearer …`, or no key at all ([Authentication](#authentication)). The description above says what each does.

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | no | Without a key: the same key within 24 hours returns the same draft, project key and claim code instead of a second draft; 1–200 characters |

Request body (application/json, [CreateProjectRequest](#createprojectrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–4,000 characters; e.g. `"I would like natural pastel colored self-watering potted plants shipped DTC on demand (with most customers in United States). Pots should be cheap and in lots of at most 100 and sent to a nursery for potting on shipping on demand. Nursery should support a variety of houseplants."` |
| `answers` | array of [Answer](#answer) | no | Without a key: answers to the questions a preview returned, each with its leg's key. With a buyer key, answer each leg's questions with POST /v1/tenders/{tenderId}/answers instead.; up to 40 items; default `[]` |
| `contact` | object or null | no | Without a key, and only with the person's consent: their name and email, to prefill the claim page (shown masked).; default `null` |
| `contact.name` | string or null | no | 2–120 characters; default `null` |
| `contact.email` | string | yes | up to 254 characters; format email |
| `agent` | object or null | no | Without a key: the agent, as it names itself, e.g. { "name": "claude-code" }.; default `null` |
| `agent.name` | string | yes | 1–80 characters |
| `agent.version` | string or null | no | up to 40 characters; default `null` |

Responses:

- `201` With a buyer key, the planned project; without one, the draft with its project key and claim link: [Project](#project) or [DraftCreated](#draftcreated)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)
- `503` The spec engine is unavailable; retry shortly: [Error](#error)

### GET /v1/projects/{id}

**Get a project, its legs and what to do next.** Works with a buyer key, the project key or the owner token. `next` says what to do now in words to repeat to the person, and when to look again (`next.checkAfter`, mirrored in `Retry-After` while waiting for a claim or for bids). Bids take days: look again when it says, not sooner.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The project: [Project](#project)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### DELETE /v1/projects/{id}

**Discard an unclaimed draft.** With the project key: the draft's legs are cancelled and its claim code stops working. Nothing ever reached a workshop. A claimed project can't be discarded.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The discarded draft: [Project](#project)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/projects/{id}/claim-codes

**Get a fresh claim code for an unclaimed draft.** With the project key, when the person is ready to look (a code lasts 15 minutes by default, up to 24 hours on request). The old code stops working. Show the person `project.next.message`: it carries the new link.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [ClaimCodeRequest](#claimcoderequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `ttlMinutes` | integer | no | How long the code works: 15 minutes by default, up to 24 hours when nobody is watching (a background run); 5–1,440; default `15` |

Responses:

- `201` The new code and link: [ClaimCodeIssued](#claimcodeissued)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/projects/{id}/events

**A project's history, oldest first.** With a buyer key, the project key or the owner token. Pass the last id you have seen as `after` to get only what's new: drafted, claimed, legs published, bids, workshops' questions, contracts. Summaries are Établi's words, never a workshop's.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |
| `after` | query | string | no | The last event id you have seen: return only newer events |
| `limit` | query | integer | no | 1–200; e.g. `100` |

Responses:

- `200` Events, oldest first: array of [ProjectEvent](#projectevent)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/projects/{id}/agent-key/revoke

**Revoke the agent's project key.** With the owner token (from the status page's link) or the buyer's key: the agent that drafted the project can no longer read it or act on it.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The project: [Project](#project)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/projects/{id}/publish

**Publish every leg.** Opens every ready leg to the workshops that qualify for it. If a leg still has open questions, nothing is published and the 400 lists the legs that aren't ready. A leg with too few qualified workshops gets Établi's sourcing. Buyer keys only: an agent's draft is published by its person's claim (403 `claim_required`, with the claim page in `details.claimUrl`).

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [PublishProjectRequest](#publishprojectrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `biddingHours` | integer | no | 1–336 |

Responses:

- `200` The project, its legs open: [Project](#project)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/projects/{id}/offers

**Compare offers on every leg.** Each leg's offers in the usual Offer shape, and the recommended offers added up into one all-in price per finished unit when every leg is priced per unit. With a buyer key, the owner token, or the project key once the person has claimed the project.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` Offers per leg: [ProjectOffers](#projectoffers)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/projects/{id}/award

**Award every leg.** Creates one contract per leg, together: legs left out of `legs` get Établi's recommendation, and nothing is awarded unless every open leg has an eligible offer. Place each leg's orders against its contract; an upstream leg's orders ship to the downstream workshop's receiving address. Buyer keys only: on a claimed draft the person chooses offers on their status page.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [AwardProjectRequest](#awardprojectrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `legs` | array of object | no | Choices per leg; legs left out get Établi's recommendation |
| `legs[].key` | string | yes | e.g. `"pots"` |
| `legs[].primaryBidId` | string | no |  |
| `legs[].backupBidId` | string or null | no | null for no backup |

Responses:

- `201` A contract per leg: [ProjectAward](#projectaward)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/claims/{code}

**Read a draft by its claim code (no API key needed).** What the claim page shows before the person decides: the plan, the questions still open, each leg's ceiling and the most it can cost, and what publishing does. Changes nothing. An expired, used or unknown code is a 404; five wrong codes from one address lock it out for 15 minutes (429).

Auth: none (no API key).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `code` | path | string | yes | The claim code, with or without its dash, in any case; 1–32 characters; e.g. `"KQ7M-4TRD"` |

Responses:

- `200` The draft, as the claim page shows it: [ClaimView](#claimview)
- `404` Not found: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

### POST /v1/claims/{code}

**Claim and publish a draft (the person, on etabli.io; no API key).** The one approval: the project becomes the person's (their buyer account is made, or found by email), the answers given here are folded in, and every leg is published to workshops. Nothing is charged. If a leg still isn't ready (a missing ceiling, open questions), nothing happens and the 409 lists what's missing in `details.legs`. Returns the status page's link with the owner token, shown once. Refused when sent with a key: agents show the link, they don't claim.

Auth: none (no API key).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `code` | path | string | yes | The claim code, with or without its dash, in any case; 1–32 characters; e.g. `"KQ7M-4TRD"` |

Request body (application/json, [ClaimRequest](#claimrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–120 characters; e.g. `"Maya Ortiz"` |
| `email` | string | yes | up to 254 characters; format email; e.g. `"maya@example.com"` |
| `company` | string or null | no | up to 120 characters; default `null` |
| `answers` | array of [Answer](#answer) | no | Questions still open on the draft, answered here, each with its leg's key; up to 40 items; default `[]` |
| `biddingHours` | integer | no | How long workshops can bid (48 hours by default); 24–336 |
| `agentMay` | array of string | no | What the agent may go on doing: `answer_clarifications` lets it answer workshops' questions; each one of `answer_clarifications`; default `[]` |
| `confirmed` | boolean | yes | The person ticked "I asked an AI agent for this"; one of `true` |

Responses:

- `201` The claimed project and the status page's link: [ClaimResult](#claimresult)
- `400` Invalid request: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

## Tenders

### GET /v1/tenders

**List your tenders, newest first.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Tenders: array of [Tender](#tender)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### POST /v1/tenders

**Post a tender in plain English.** Compiles the request into a spec and returns the questions, if any, the buyer must answer before publishing.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Request body (application/json, [CreateTenderRequest](#createtenderrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–4,000 characters; e.g. `"Paying up to $5 to intake pots and on demand fill them with soil and a plant and ship to customer with a max delay of 2 business days."` |

Responses:

- `201` The compiled tender: [Tender](#tender)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)
- `503` The spec engine is unavailable; retry shortly: [Error](#error)

### GET /v1/tenders/{id}

**Get a tender.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The tender: [Tender](#tender)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/tenders/{id}/answers

**Answer clarifying questions.** Folds the answers into the spec. The tender becomes `ready` when nothing is left to ask. For a leg of an unclaimed draft, the project key answers too.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [AnswerQuestionsRequest](#answerquestionsrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `answers` | array of object | no | default `[]` |
| `answers[].questionId` | string | yes |  |
| `answers[].answer` | string | yes | 1–1,000 characters |
| `acceptSuggested` | boolean | no | Answer every open question that has a suggested answer with that suggestion; default `false` |

Responses:

- `200` The updated tender: [Tender](#tender)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `503` The spec engine is unavailable; retry shortly: [Error](#error)

### POST /v1/tenders/{id}/publish

**Publish a ready tender to matching workshops.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [PublishTenderRequest](#publishtenderrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `biddingHours` | integer | no | 1–336 |

Responses:

- `200` The open tender, with how many workshops were invited: [Tender](#tender)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/tenders/{id}/cancel

**Cancel a tender that has not been awarded.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The cancelled tender: [Tender](#tender)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/tenders/{id}/offers

**Compare offers.** Each bid as the buyer would pay it, ranked, with Établi's recommended primary and backup.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` Ranked offers: array of [Offer](#offer)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/tenders/{id}/award

**Award the tender and create a contract.** With an empty body, awards Établi's recommendation: the best offer as primary, the next as backup.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [AwardTenderRequest](#awardtenderrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `primaryBidId` | string | no | Defaults to Établi's recommendation |
| `backupBidId` | string or null | no | null for no backup |

Responses:

- `201` The new contract: [Contract](#contract)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/tenders/{id}/clarifications

**Questions about a tender.** Buyers see every question and who asked it (so does the owner token, and the project key if the person let the agent answer them). Invited workshops see answered questions and their own.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` Questions, oldest first: array of [Clarification](#clarification)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/clarifications/{id}/answer

**Answer a workshop's question.** The answer is shared with every invited workshop and added to the spec's rules, so it is part of the contract. The buyer answers (on a claimed project, with the owner token from their status page), or the project key if the person allowed `answer_clarifications` when claiming.

Auth: an API key, a project key or an owner token, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [AnswerClarificationRequest](#answerclarificationrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `answer` | string | yes | 1–2,000 characters; e.g. `"Street parking only; it's free before 10am."` |

Responses:

- `200` The answered question: [Clarification](#clarification)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

## Contracts

### GET /v1/contracts

**List contracts you are party to.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Contracts: array of [Contract](#contract)
- `401` Missing or unknown API key: [Error](#error)

### GET /v1/contracts/{id}

**Get a contract.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The contract: [Contract](#contract)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)

### GET /v1/contracts/{id}/statement

**Weekly statement: orders, charges, payouts and fees.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |
| `week` | query | string | no | ISO week; defaults to the current week; pattern `^\d{4}-W\d{2}$`; e.g. `"2026-W41"` |

Responses:

- `200` The statement: [Statement](#statement)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)

## Orders

### POST /v1/contracts/{id}/orders

**Place an order.** One call per unit of work: one customer's purchase, one recipient's parcel, one batch, or one lot for a leg that supplies another leg (without an address, it ships to the receiving address of the workshop awarded the leg it feeds). Routed to the primary or backup workshop.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |
| `Idempotency-Key` | header | string | no | Retrying with the same key returns the original order instead of a duplicate; up to 200 characters |

Request body (application/json, [CreateOrderRequest](#createorderrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `quantity` | integer | no | 1–100,000; default `1` |
| `reference` | string or null | no | up to 200 characters; e.g. `"shopify-#1042"` |
| `address` | object or null | no | Where the order ships to, or where the work happens. Required for on-site work and for contracts that ship to end customers. |
| `address.name` | string | yes | 1–120 characters |
| `address.line1` | string | yes | 1–200 characters |
| `address.line2` | string or null | no | up to 200 characters; default `null` |
| `address.city` | string | yes | 1–100 characters |
| `address.region` | string | yes | 2–2 characters |
| `address.postalCode` | string | yes | 3–12 characters |
| `address.country` | string | no | one of `US`; default `"US"` |
| `scheduledFor` | string or null | no | When the work should happen. Required when the contract's deadline is `scheduled` (e.g. an on-site visit or a weekly pickup).; format date-time; e.g. `"2026-10-13T08:00:00-05:00"` |

Responses:

- `200` An existing order with this idempotency key: [Order](#order)
- `201` The new order: [Order](#order)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/orders

**List orders.** Buyers see their orders; workshops see orders routed to them (their work queue).

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `contractId` | query | string | no |  |
| `status` | query | string | no | one of `routed`, `accepted`, `shipped`, `completed`, `failed`, `cancelled` |

Responses:

- `200` Orders, newest first: array of [Order](#order)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)

### GET /v1/orders/{id}

**Get an order with its proofs.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The order: [Order](#order)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/orders/{id}/accept

**Accept an order routed to your workshop.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The order: [Order](#order)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/orders/{id}/proofs

**Add proof: a tracking number (marks the order shipped), photo, signature….**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [ProofRequest](#proofrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | yes | one of `tracking_number`, `photo`, `signature`, `document`, `video` |
| `value` | string | yes | A tracking number, or an https:// link for photos, videos and documents; 1–2,000 characters; e.g. `"9400111899223197428490"` |
| `carrier` | string or null | no | up to 60 characters; e.g. `"USPS"` |

Responses:

- `200` The order with its proofs: [Order](#order)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/orders/{id}/complete

**Complete an order (pays the workshop).** Delivered, for orders that ship (in production a carrier webhook calls this), or done, for on-site work. Workshops must first add the proof the spec asks for: a tracking number for orders that ship, otherwise a photo or other listed proof.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The completed order: [Order](#order)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/orders/{id}/cancel

**Cancel an order before it ships or is done (refunds the buyer).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The cancelled order: [Order](#order)
- `401` Missing or unknown API key: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

## Accounts

### GET /v1/me

**The account behind this API key.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Your account: [Me](#me)
- `401` Missing or unknown API key: [Error](#error)

## Support

### POST /v1/support-requests

**Contact Établi (no API key needed).** Sends a message to Établi: the contact form on etabli.io/support. Établi replies to the email given. `source` is the page or app the person came from, e.g. `chatgpt`.

Auth: none (no API key).

Request body (application/json, [SupportRequestRequest](#supportrequestrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | up to 254 characters; format email |
| `topic` | string | no | one of `buying`, `workshop`, `api`, `privacy`, `other`; default `"other"` |
| `message` | string | yes | 10–4,000 characters |
| `source` | string or null | no | up to 120 characters; default `null` |

Responses:

- `201` Request received: an object with `ok` (boolean), `id` (string)
- `400` Invalid request: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

## Workshops

### GET /v1/invitations

**Open tenders you're invited to bid on.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Invitations: array of [Invitation](#invitation)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### PUT /v1/tenders/{id}/bid

**Place or replace your bid.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [BidRequest](#bidrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `unitPriceCents` | integer | yes | Your net price per unit, before Établi's fee; e.g. `430` |
| `weeklyCapacity` | integer | yes | e.g. `300` |
| `leadTimeDays` | integer or null | no | 0–365 |
| `note` | string or null | no | up to 500 characters |
| `portfolio` | array of string | no | Up to six photo URLs of similar past work, shown to the buyer with your bid; up to 6 items; e.g. `["https://example.com/work/quarter-zip-embroidery.jpg"]` |

Responses:

- `200` Your bid: [Bid](#bid)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)

### DELETE /v1/tenders/{id}/bid

**Withdraw your bid.**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The withdrawn bid: [Bid](#bid)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/tenders/{id}/clarifications

**Ask the buyer a question before you bid.** The buyer sees who asked. Other invited workshops see the question once it is answered, without your name.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [AskClarificationRequest](#askclarificationrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `question` | string | yes | 3–1,000 characters; e.g. `"Is there parking on site, or should we plan for street parking?"` |

Responses:

- `201` Your question: [Clarification](#clarification)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/workshop-applications

**Apply to join as a workshop (no API key needed).** For one person or a company. An operator reviews each application and gets in touch about first jobs.

Auth: none (no API key).

Request body (application/json, [WorkshopApplicationRequest](#workshopapplicationrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `entity` | string | yes | one of `individual`, `business` |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | format email |
| `description` | string | yes | 20–2,000 characters |
| `trades` | array of string | no | up to 12 items; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture`; default `[]` |
| `area` | string | yes | 2–80 characters |
| `country` | string | no | 2–2 characters; default `"US"` |
| `website` | string or null | no | up to 300 characters; default `null` |
| `weeklyCapacity` | integer or null | no | up to 100,000; default `null` |
| `note` | string or null | no | up to 1,000 characters; default `null` |

Responses:

- `201` Application received: an object with `ok` (boolean), `id` (string)
- `400` Invalid request: [Error](#error)

## Bid links

### GET /v1/bid-links/{token}

**See the job behind a bid link (no API key needed).** The leg as any invited workshop sees it, without the buyer: the spec, the most a bid can be, answered questions, when bidding closes, and whether the link can still be used. Once the leg is no longer open or `closesAt` has passed, this still answers 200, with `status` `closed`.

Auth: none (no API key).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `token` | path | string | yes | 10–100 characters |

Responses:

- `200` The job: [BidLinkView](#bidlinkview)
- `404` Not found: [Error](#error)

### POST /v1/bid-links/{token}/bid

**Bid through a bid link (no API key needed).** Joins Établi as a workshop (the first time) and places or replaces the bid: the net price per unit, before Établi's fee. The business's email is required (Établi sends the contract and orders there); a phone is optional. Both are kept on the workshop. Answers 409 once bidding has closed.

Auth: none (no API key).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `token` | path | string | yes | 10–100 characters |

Request body (application/json, [BidLinkBidRequest](#bidlinkbidrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business` | object | yes |  |
| `business.name` | string | yes | 2–160 characters |
| `business.entity` | string | yes | one of `individual`, `business` |
| `business.email` | string | yes | format email |
| `business.phone` | string or null | no | up to 40 characters; default `null` |
| `business.website` | string or null | no | up to 300 characters; default `null` |
| `business.country` | string | yes | 2–2 characters |
| `business.region` | string or null | no | up to 80 characters; default `null` |
| `business.city` | string or null | no | up to 80 characters; default `null` |
| `business.description` | string | yes | 20–2,000 characters |
| `unitPriceCents` | integer | yes |  |
| `weeklyCapacity` | integer | yes |  |
| `leadTimeDays` | integer or null | no | 0–365; default `null` |
| `note` | string or null | no | up to 500 characters; default `null` |
| `portfolio` | array of string | no | up to 6 items; default `[]` |

Responses:

- `201` Bid placed: an object with `ok` (boolean), `bidId` (string)
- `400` Invalid request: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

### POST /v1/bid-links/{token}/questions

**Ask the buyer a question through a bid link (no API key needed).** Every bidder sees the answer once given (without who asked), and it becomes part of the contract. Only a name is needed; an email or phone, if given, is kept with the workshop. Answers 409 once bidding has closed.

Auth: none (no API key).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `token` | path | string | yes | 10–100 characters |

Request body (application/json, [BidLinkQuestionRequest](#bidlinkquestionrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business` | object | yes |  |
| `business.name` | string | yes | 2–160 characters |
| `business.phone` | string or null | no | up to 40 characters; default `null` |
| `business.email` | string or null | no | format email; default `null` |
| `question` | string | yes | 3–1,000 characters |

Responses:

- `201` The question: [Clarification](#clarification)
- `400` Invalid request: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

### POST /v1/bid-links/{token}/decline

**Decline through a bid link (no API key needed).** Answers 409 once bidding has closed, or after a bid through this link.

Auth: none (no API key).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `token` | path | string | yes | 10–100 characters |

Request body (application/json, [BidLinkDeclineRequest](#bidlinkdeclinerequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `reason` | string or null | no | up to 1,000 characters; default `null` |

Responses:

- `200` Declined: an object with `ok` (boolean)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `429` Too many requests from this address; see Retry-After: [Error](#error)

## System

### GET /

**Service info.**

Auth: none (no API key).

Responses:

- `200` Service info: an object with `name` (string), `docs` (string), `openapi` (string), `specEngine` (string)

### GET /health

**Liveness check.**

Auth: none (no API key).

Responses:

- `200` Healthy: an object with `ok` (boolean)

### POST /v1/waitlist

**Request early access (used by the website).**

Auth: none (no API key).

Request body (application/json, [WaitlistRequest](#waitlistrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `email` | string | yes | format email |
| `role` | string | yes | one of `buyer`, `workshop` |
| `note` | string or null | no | up to 1,000 characters |

Responses:

- `201` Signed up: an object with `ok` (boolean)
- `400` Invalid request: [Error](#error)

## Operators

### GET /v1/workshops

**List workshops (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Workshops: array of [Workshop](#workshop)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### POST /v1/workshops

**Onboard a workshop (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Request body (application/json, [OnboardWorkshopRequest](#onboardworkshoprequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–120 characters |
| `entity` | string | no | one of `individual`, `business`; default `"business"` |
| `email` | string or null | no | Where Établi sends contracts and orders; also the account's email unless another account has it; format email |
| `description` | string | no | What they make or do, in their own words (embedded for matching); up to 2,000 characters |
| `capabilities` | array of string | yes | not empty; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture` |
| `certifications` | array of string | no | each one of `nursery_license`, `fda_food_facility`, `food_handler`, `general_liability`, `background_checked`, `bonded`; default `[]` |
| `country` | string | no | 2–2 characters; default `"US"` |
| `region` | string | yes | Two-letter state code in the US; province or region elsewhere; 1–80 characters |
| `serviceArea` | string or null | no | City covered for on-site work; up to 80 characters; e.g. `"Austin, TX"` |
| `postalCode` | string or null | no | up to 12 characters |
| `receivingAddress` | object or null | no | Where another leg's output is delivered to them (e.g. pots for a nursery) |
| `receivingAddress.name` | string | yes | 1–120 characters |
| `receivingAddress.line1` | string | yes | 1–200 characters |
| `receivingAddress.line2` | string or null | no | up to 200 characters; default `null` |
| `receivingAddress.city` | string | yes | 1–100 characters |
| `receivingAddress.region` | string | yes | 2–2 characters |
| `receivingAddress.postalCode` | string | yes | 3–12 characters |
| `receivingAddress.country` | string | no | one of `US`; default `"US"` |
| `phone` | string or null | no | up to 40 characters |
| `weeklyCapacity` | integer or null | no |  |

Responses:

- `201` The workshop and its first API key: [OnboardedWorkshop](#onboardedworkshop)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### GET /v1/workshop-applications

**List workshop applications (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Applications, newest first: array of [WorkshopApplication](#workshopapplication)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### POST /v1/workshop-applications/{id}/approve

**Approve an application and onboard the workshop (operators only).** Creates the workshop from the application (its description is embedded for matching) and returns its first API key, shown once.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [ApproveApplicationRequest](#approveapplicationrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `capabilities` | array of string | no | Override the trades they picked; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture` |
| `region` | string | no | State code in the US (read from their area when omitted); up to 80 characters |
| `serviceArea` | string or null | no | City covered for on-site work, e.g. Austin, TX; up to 80 characters |

Responses:

- `201` The workshop and its first API key: an object with `workshop` ([Workshop](#workshop)), `apiKey` (string)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/workshop-applications/{id}/decline

**Decline an application (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [DeclineApplicationRequest](#declineapplicationrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `reason` | string or null | no | up to 1,000 characters |

Responses:

- `200` The application: [WorkshopApplication](#workshopapplication)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/quote-requests

**List quote requests (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Quote requests, newest first: array of [QuoteRequest](#quoterequest)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### POST /v1/quote-requests/{id}/project

**Create the project for a quote request (operators only).** Creates (or reuses) the requester's buyer account and a project, planned into legs, with the answers they gave on the quote page. Returns a buyer API key, shown once, for publishing and awarding on their behalf.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `201` The project, its buyer and a buyer key: an object with `project` ([Project](#project)), `buyer` ([Account](#account)), `apiKey` (string)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `422` Refused by policy or price rules: [Error](#error)
- `503` The spec engine is unavailable; retry shortly: [Error](#error)

### GET /v1/support-requests

**List support requests (operators only).** Newest first. `status` is `new` or `handled`; leave it out for both.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | one of `new`, `handled` |

Responses:

- `200` Support requests, newest first: array of [SupportRequest](#supportrequest)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### POST /v1/support-requests/{id}/handled

**Mark a support request handled (operators only).** Sets `handledAt`. Idempotent: a request that is already handled comes back unchanged.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The support request: [SupportRequest](#supportrequest)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### GET /v1/admin/overview

**Counts and what needs you (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` The overview: [AdminOverview](#adminoverview)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### GET /v1/admin/activity

**Activity, newest first (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `before` | query | string | no | An event id: return events older than it (the next page) |
| `limit` | query | integer | no | 1–200; e.g. `50` |

Responses:

- `200` Events with readable summaries: array of [ActivityEvent](#activityevent)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### GET /v1/admin/projects

**Projects with who drafted and who claimed them (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `claim` | query | string | no | Only projects in this claim status, e.g. `unclaimed` for agents' drafts; one of `unclaimed`, `claimed`, `expired`, `discarded` |

Responses:

- `200` Projects, newest first: array of [AdminProject](#adminproject)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

### GET /v1/admin/projects/{id}

**A project with who drafted and who claimed it (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The project: [AdminProject](#adminproject)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### GET /v1/admin/agents

**Who sends work, by agent family, last 7 and 30 days (operators only).** Previews, keyless drafts, claims (counted under the family that drafted the project) and quote requests, from daily counters.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Counts per agent family: [AgentActivity](#agentactivity)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)

## Sourcing

### GET /v1/tenders/{id}/matches

**Workshops and prospects ranked for a leg (operators only).** Every workshop and prospect considered for the leg: similarity of their description to the leg, why each is in or out, and whether they were invited or contacted.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` Matches: array of [LegMatch](#legmatch)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/tenders/{id}/sourcing

**Run sourcing for a leg (operators only).** Links known prospects that fit, asks the sourcing agent (web search) for up to eight new ones, and drafts a message with a bid link to each. Drafts wait for approval.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` Prospects and drafts: [SourcingRun](#sourcingrun)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)
- `503` The spec engine is unavailable; retry shortly: [Error](#error)

### GET /v1/prospects

**List prospects (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | one of `new`, `contacted`, `interested`, `bid`, `declined`, `onboarded`, `do_not_contact` |
| `tenderId` | query | string | no |  |

Responses:

- `200` Prospects, newest first: array of [Prospect](#prospect)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/prospects

**Add a prospect by hand (operators only).** With a `tenderId`, the prospect is linked to that leg and a draft message is written for approval.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Request body (application/json, [ProspectRequest](#prospectrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–160 characters |
| `website` | string or null | no | up to 300 characters; default `null` |
| `country` | string or null | no | 2–2 characters; default `null` |
| `region` | string or null | no | up to 80 characters; default `null` |
| `city` | string or null | no | up to 80 characters; default `null` |
| `description` | string | yes | 3–2,000 characters |
| `contacts` | array of object | no | up to 10 items; default `[]` |
| `contacts[].kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `contacts[].value` | string | yes | 1–500 characters |
| `contacts[].label` | string or null | yes | up to 120 characters |
| `tenderId` | string or null | no | default `null` |
| `notes` | string or null | no | up to 2,000 characters; default `null` |

Responses:

- `201` The prospect: [Prospect](#prospect)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### PATCH /v1/prospects/{id}

**Edit a prospect (operators only).** Only the fields sent change. Status `do_not_contact` rejects every unsent message to them.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [ProspectEditRequest](#prospecteditrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | no | 2–160 characters |
| `website` | string or null | no | up to 300 characters |
| `country` | string or null | no | 2–2 characters |
| `region` | string or null | no | up to 80 characters |
| `city` | string or null | no | up to 80 characters |
| `description` | string | no | 3–2,000 characters |
| `contacts` | array of object | no | up to 10 items |
| `contacts[].kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `contacts[].value` | string | yes | 1–500 characters |
| `contacts[].label` | string or null | yes | up to 120 characters |
| `fit` | string or null | no | up to 1,000 characters |
| `status` | string | no | one of `new`, `contacted`, `interested`, `bid`, `declined`, `onboarded`, `do_not_contact` |
| `notes` | string or null | no | up to 2,000 characters |
| `tenderId` | string | no |  |

Responses:

- `200` The prospect: [Prospect](#prospect)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

## Outreach

### GET /v1/outreach

**List outreach messages (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | one of `draft`, `approved`, `sent`, `failed`, `rejected`, `replied` |
| `tenderId` | query | string | no |  |

Responses:

- `200` Messages, newest first: array of [OutreachMessage](#outreachmessage)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### PATCH /v1/outreach/{id}

**Edit a message before it is sent (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [OutreachMessageEditRequest](#outreachmessageeditrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `subject` | string | no | 1–200 characters |
| `body` | string | no | 1–10,000 characters |
| `channelId` | string or null | no |  |
| `to` | object or null | no |  |
| `to.kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `to.value` | string | yes | 1–500 characters |
| `to.label` | string or null | yes | up to 120 characters |

Responses:

- `200` The message: [OutreachMessage](#outreachmessage)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/outreach/{id}/approve

**Approve a message (operators only).** An email channel sends it now (`sent`, or `failed` with the error; approve again to retry). A manual channel leaves it `approved` for you to send by hand and mark sent.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The message: [OutreachMessage](#outreachmessage)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/outreach/{id}/sent

**Mark an approved message sent by hand (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The message: [OutreachMessage](#outreachmessage)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### POST /v1/outreach/{id}/reject

**Reject a draft (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` The message: [OutreachMessage](#outreachmessage)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/outreach/{id}/replies

**A message's replies, oldest first (operators only).** Listing them marks them read.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Responses:

- `200` Replies: array of [OutreachReply](#outreachreply)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/outreach/{id}/replies

**Record a prospect's reply (operators only).** Classified as interested, declined, question or other, with the next step.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [OutreachReplyRequest](#outreachreplyrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `body` | string | yes | 1–20,000 characters; e.g. `"Yes, we can do lots of 100."` |

Responses:

- `201` The reply: [OutreachReply](#outreachreply)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### GET /v1/outreach-channels

**List outreach channels (operators only).**

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Responses:

- `200` Channels: array of [OutreachChannel](#outreachchannel)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)

### POST /v1/outreach-channels

**Add an outreach channel (operators only).** `email` sends through Resend with `secret` as its API key (stored encrypted, never returned); `manual` is sent by hand.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

Request body (application/json, [OutreachChannelRequest](#outreachchannelrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | yes | one of `email`, `manual` |
| `name` | string | yes | 2–80 characters |
| `settings` | [Settings](#settings) | yes |  |
| `secret` | string or null | no | 1–500 characters |
| `active` | boolean | no | default `true` |

Responses:

- `201` The channel: [OutreachChannel](#outreachchannel)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

### PATCH /v1/outreach-channels/{id}

**Edit an outreach channel (operators only).** Omit `secret` to keep the current one; send null to clear it.

Auth: an API key, as `Authorization: Bearer …` ([Authentication](#authentication)).

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | at least 4 characters; e.g. `"tnd_01k7f3x9qz8m2c4v6b0n5r7t9w"` |

Request body (application/json, [OutreachChannelEditRequest](#outreachchanneleditrequest)):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | no | one of `email`, `manual` |
| `name` | string | no | 2–80 characters |
| `settings` | [Settings](#settings) | no |  |
| `secret` | string or null | no | 1–500 characters |
| `active` | boolean | no |  |

Responses:

- `200` The channel: [OutreachChannel](#outreachchannel)
- `400` Invalid request: [Error](#error)
- `401` Missing or unknown API key: [Error](#error)
- `403` Not allowed for this account type: [Error](#error)
- `404` Not found: [Error](#error)
- `409` Conflicts with the resource's current state: [Error](#error)

## Schemas

Objects the endpoints take and return. Money is integer US cents; times are ISO 8601.

### QuotePreviewRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–2,000 characters |
| `answers` | array of [Answer](#answer) | no | up to 20 items; default `[]` |

### Answer

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `leg` | string or null | no | up to 40 characters; default `null` |
| `field` | string | yes | 1–100 characters |
| `text` | string | yes | 1–500 characters |
| `answer` | string | yes | 1–1,000 characters |

### PlanPreview

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `title` | string | yes |  |
| `summary` | string | yes |  |
| `legs` | array of object | yes | not empty |
| `legs[].key` | string | yes | pattern `^[a-z0-9][a-z0-9-]{0,39}$` |
| `legs[].title` | string | yes |  |
| `legs[].role` | string | yes |  |
| `legs[].spec` | [Spec](#spec) | yes |  |
| `legs[].questions` | array of object | yes |  |
| `legs[].questions[].field` | string | yes |  |
| `legs[].questions[].text` | string | yes |  |
| `legs[].questions[].why` | string | yes |  |
| `legs[].questions[].suggestedAnswer` | string or null | yes |  |
| `legs[].inputsFrom` | array of object | yes |  |
| `legs[].inputsFrom[].fromLeg` | string | yes |  |
| `legs[].inputsFrom[].item` | string | yes |  |
| `legs[].sourcing` | [Sourcing](#sourcing) | yes |  |
| `engine` | string | yes |  |

### Spec

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `title` | string | yes | 1–120 characters |
| `summary` | string | yes | up to 600 characters |
| `kind` | string | yes | one of `transform_and_ship`, `batch_production`, `on_site_service`, `field_data`, `other` |
| `capabilities` | array of string | yes | not empty; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture` |
| `inputs` | array of object | yes |  |
| `inputs[].item` | string | yes | not empty |
| `inputs[].suppliedBy` | string | yes | one of `buyer`, `workshop`, `leg` |
| `inputs[].notes` | string or null | yes |  |
| `inputs[].fromLeg` | string or null | no | default `null` |
| `work` | string | yes | not empty |
| `output` | string | yes | not empty |
| `price` | object | yes |  |
| `price.ceilingCents` | integer or null | yes |  |
| `price.per` | string | yes | one of `unit`, `job`, `hour`, `head`, `visit` |
| `price.includes` | array of string | yes |  |
| `price.excludes` | array of string | yes |  |
| `deadline` | object | yes |  |
| `deadline.kind` | string | yes | one of `per_order`, `fixed_date`, `scheduled`, `flexible` |
| `deadline.businessDays` | integer or null | yes |  |
| `deadline.date` | string or null | yes | format date |
| `proof` | array of string | yes | not empty; each one of `tracking_number`, `photo`, `signature`, `document`, `video` |
| `where` | object | yes |  |
| `where.shipTo` | string | yes |  |
| `where.excludedRegions` | array of string | yes |  |
| `where.serviceArea` | string or null | yes |  |
| `rules` | object | yes |  |
| `rules.certifications` | array of string | yes | each one of `nursery_license`, `fda_food_facility`, `food_handler`, `general_liability`, `background_checked`, `bonded` |
| `rules.notes` | array of string | yes |  |
| `volume` | object | yes |  |
| `volume.perWeek` | integer or null | yes |  |
| `volume.peakPerWeek` | integer or null | yes |  |
| `volume.totalUnits` | integer or null | yes |  |
| `ordering` | string | yes | one of `per_order`, `one_off` |
| `assumptions` | array of string | yes |  |

### Sourcing

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `lookFor` | string | yes | 3–500 characters |
| `searchTerms` | array of string | yes | up to 8 items |
| `countries` | array of string or null | yes |  |
| `preferOnshore` | boolean | yes |  |

### Error

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `error` | object | yes |  |
| `error.code` | string | yes | e.g. `"not_found"` |
| `error.message` | string | yes |  |
| `error.details` | any | no |  |

### QuoteRequestRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–2,000 characters |
| `answers` | array of [Answer](#answer) | no | up to 20 items; default `[]` |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | format email |
| `company` | string or null | no | up to 120 characters; default `null` |
| `note` | string or null | no | up to 1,000 characters; default `null` |

### Project

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `buyerId` | string or null | yes |  |
| `claim` | object | yes |  |
| `claim.status` | string | yes | one of `unclaimed`, `claimed`, `expired`, `discarded` |
| `claim.expiresAt` | string or null | yes | format date-time |
| `claim.claimedAt` | string or null | yes | format date-time |
| `draftedBy` | object or null | yes |  |
| `draftedBy.name` | string | yes | 1–80 characters |
| `draftedBy.version` | string or null | no | up to 40 characters; default `null` |
| `next` | object or null | yes |  |
| `next.action` | string | yes | one of `show_claim_link`, `answer_questions`, `wait_for_claim`, `wait_for_bids`, `review_offers`, `awarded`, `expired`, `discarded` |
| `next.message` | string | yes |  |
| `next.url` | string or null | yes |  |
| `next.checkAfter` | string or null | yes | format date-time |
| `request` | string | yes |  |
| `title` | string | yes |  |
| `summary` | string | yes |  |
| `status` | string | yes | one of `needs_clarification`, `ready`, `open`, `awarded`, `cancelled`, `declined` |
| `legs` | array of object | yes | not empty |
| `legs[].key` | string | yes | pattern `^[a-z0-9][a-z0-9-]{0,39}$` |
| `legs[].tenderId` | string | yes |  |
| `legs[].title` | string | yes |  |
| `legs[].role` | string | yes |  |
| `legs[].status` | string | yes | one of `needs_clarification`, `ready`, `open`, `awarded`, `cancelled`, `declined` |
| `legs[].spec` | [Spec](#spec) | yes |  |
| `legs[].questions` | array of [Question](#question) | yes |  |
| `legs[].inputsFrom` | array of object | yes |  |
| `legs[].inputsFrom[].fromLeg` | string | yes |  |
| `legs[].inputsFrom[].item` | string | yes |  |
| `legs[].sourcing` | [Sourcing](#sourcing) | yes |  |
| `legs[].invitedWorkshops` | integer | yes |  |
| `legs[].biddingClosesAt` | string or null | yes | format date-time |
| `legs[].contractId` | string or null | yes |  |
| `engine` | string | yes |  |
| `createdAt` | string | yes | format date-time |
| `updatedAt` | string | yes | format date-time |

### Question

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `field` | string | yes |  |
| `text` | string | yes |  |
| `why` | string | yes |  |
| `suggestedAnswer` | string or null | yes |  |
| `answer` | string or null | yes |  |

### CreateProjectRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–4,000 characters; e.g. `"I would like natural pastel colored self-watering potted plants shipped DTC on demand (with most customers in United States). Pots should be cheap and in lots of at most 100 and sent to a nursery for potting on shipping on demand. Nursery should support a variety of houseplants."` |
| `answers` | array of [Answer](#answer) | no | Without a key: answers to the questions a preview returned, each with its leg's key. With a buyer key, answer each leg's questions with POST /v1/tenders/{tenderId}/answers instead.; up to 40 items; default `[]` |
| `contact` | object or null | no | Without a key, and only with the person's consent: their name and email, to prefill the claim page (shown masked).; default `null` |
| `contact.name` | string or null | no | 2–120 characters; default `null` |
| `contact.email` | string | yes | up to 254 characters; format email |
| `agent` | object or null | no | Without a key: the agent, as it names itself, e.g. { "name": "claude-code" }.; default `null` |
| `agent.name` | string | yes | 1–80 characters |
| `agent.version` | string or null | no | up to 40 characters; default `null` |

### DraftCreated

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `project` | [Project](#project) | yes |  |
| `projectKey` | string | yes |  |
| `claim` | object | yes |  |
| `claim.url` | string | yes |  |
| `claim.code` | string | yes |  |
| `claim.codeExpiresAt` | string | yes | format date-time |

### ClaimCodeRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `ttlMinutes` | integer | no | How long the code works: 15 minutes by default, up to 24 hours when nobody is watching (a background run); 5–1,440; default `15` |

### ClaimCodeIssued

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `project` | [Project](#project) | yes |  |
| `claim` | object | yes |  |
| `claim.url` | string | yes |  |
| `claim.code` | string | yes |  |
| `claim.codeExpiresAt` | string | yes | format date-time |

### ProjectEvent

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `type` | string | yes |  |
| `legKey` | string or null | yes |  |
| `summary` | string | yes |  |
| `createdAt` | string | yes | format date-time |

### PublishProjectRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `biddingHours` | integer | no | 1–336 |

### ProjectOffers

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `legs` | array of object | yes |  |
| `legs[].key` | string | yes | pattern `^[a-z0-9][a-z0-9-]{0,39}$` |
| `legs[].title` | string | yes |  |
| `legs[].tenderId` | string | yes |  |
| `legs[].offers` | array of [Offer](#offer) | yes |  |
| `combined` | object | yes |  |
| `combined.perUnitCents` | integer or null | yes |  |
| `combined.note` | string | yes |  |

### Offer

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `bidId` | string | yes |  |
| `workshop` | object | yes |  |
| `workshop.id` | string | yes |  |
| `workshop.name` | string | yes |  |
| `workshop.entity` | string | yes | one of `individual`, `business` |
| `workshop.country` | string | yes |  |
| `workshop.region` | string | yes |  |
| `workshop.rating` | number | yes |  |
| `workshop.onTimeRate` | number | yes |  |
| `workshop.completedOrders` | integer | yes |  |
| `buyerUnitPriceCents` | integer | yes |  |
| `weeklyCapacity` | integer | yes |  |
| `leadTimeDays` | integer or null | yes |  |
| `note` | string or null | yes |  |
| `portfolio` | array of string | yes |  |
| `withinCeiling` | boolean | yes |  |
| `score` | number | yes |  |
| `recommended` | string or null | yes | one of `primary`, `backup`, `null` |

### AwardProjectRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `legs` | array of object | no | Choices per leg; legs left out get Établi's recommendation |
| `legs[].key` | string | yes | e.g. `"pots"` |
| `legs[].primaryBidId` | string | no |  |
| `legs[].backupBidId` | string or null | no | null for no backup |

### ProjectAward

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `contracts` | array of object | yes |  |
| `contracts[].leg` | string | yes | pattern `^[a-z0-9][a-z0-9-]{0,39}$` |
| `contracts[].contract` | [Contract](#contract) | yes |  |

### Contract

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenderId` | string | yes |  |
| `buyerId` | string | yes |  |
| `title` | string | yes |  |
| `primaryWorkshopId` | string | yes |  |
| `backupWorkshopId` | string or null | yes |  |
| `buyerUnitPriceCents` | integer | yes |  |
| `primaryUnitPriceCents` | integer | yes |  |
| `backupUnitPriceCents` | integer or null | yes |  |
| `feeRate` | number | yes | 0–0.5 |
| `backupShare` | number | yes | 0–0.5 |
| `orderCount` | integer | yes |  |
| `status` | string | yes | one of `active`, `paused`, `ended` |
| `createdAt` | string | yes | format date-time |

### ClaimView

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `project` | [Project](#project) | yes |  |
| `draftedAt` | string | yes | format date-time |
| `code` | string | yes |  |
| `codeExpiresAt` | string | yes | format date-time |
| `contactHint` | object or null | yes |  |
| `contactHint.name` | string or null | yes |  |
| `contactHint.emailMasked` | string | yes |  |
| `maxCostCents` | integer or null | yes |  |
| `publishing` | object | yes |  |
| `publishing.maxInvites` | integer | yes |  |
| `publishing.minInvites` | integer | yes |  |
| `publishing.defaultBiddingHours` | integer | yes |  |

### ClaimRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–120 characters; e.g. `"Maya Ortiz"` |
| `email` | string | yes | up to 254 characters; format email; e.g. `"maya@example.com"` |
| `company` | string or null | no | up to 120 characters; default `null` |
| `answers` | array of [Answer](#answer) | no | Questions still open on the draft, answered here, each with its leg's key; up to 40 items; default `[]` |
| `biddingHours` | integer | no | How long workshops can bid (48 hours by default); 24–336 |
| `agentMay` | array of string | no | What the agent may go on doing: `answer_clarifications` lets it answer workshops' questions; each one of `answer_clarifications`; default `[]` |
| `confirmed` | boolean | yes | The person ticked "I asked an AI agent for this"; one of `true` |

### ClaimResult

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `project` | [Project](#project) | yes |  |
| `statusUrl` | string | yes |  |

### Tender

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `buyerId` | string or null | yes |  |
| `request` | string | yes |  |
| `projectId` | string or null | yes |  |
| `legKey` | string or null | yes |  |
| `status` | string | yes | one of `needs_clarification`, `ready`, `open`, `awarded`, `cancelled`, `declined` |
| `spec` | [Spec](#spec) | yes |  |
| `questions` | array of [Question](#question) | yes |  |
| `specEngine` | string | yes |  |
| `declineReason` | string or null | yes |  |
| `invitedWorkshops` | integer | yes |  |
| `biddingClosesAt` | string or null | yes | format date-time |
| `createdAt` | string | yes | format date-time |
| `updatedAt` | string | yes | format date-time |

### CreateTenderRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `request` | string | yes | 10–4,000 characters; e.g. `"Paying up to $5 to intake pots and on demand fill them with soil and a plant and ship to customer with a max delay of 2 business days."` |

### AnswerQuestionsRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `answers` | array of object | no | default `[]` |
| `answers[].questionId` | string | yes |  |
| `answers[].answer` | string | yes | 1–1,000 characters |
| `acceptSuggested` | boolean | no | Answer every open question that has a suggested answer with that suggestion; default `false` |

### PublishTenderRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `biddingHours` | integer | no | 1–336 |

### AwardTenderRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `primaryBidId` | string | no | Defaults to Établi's recommendation |
| `backupBidId` | string or null | no | null for no backup |

### Clarification

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenderId` | string | yes |  |
| `question` | string | yes | 3–1,000 characters |
| `answer` | string or null | yes |  |
| `askedBy` | object or null | yes |  |
| `askedBy.workshopId` | string | yes |  |
| `askedBy.name` | string | yes |  |
| `createdAt` | string | yes | format date-time |
| `answeredAt` | string or null | yes | format date-time |

### AnswerClarificationRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `answer` | string | yes | 1–2,000 characters; e.g. `"Street parking only; it's free before 10am."` |

### Statement

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `contractId` | string | yes |  |
| `week` | string | yes |  |
| `ordersPlaced` | integer | yes |  |
| `ordersCompleted` | integer | yes |  |
| `buyerChargesCents` | integer | yes |  |
| `refundsCents` | integer | yes |  |
| `workshopPayoutsCents` | integer | yes |  |
| `platformFeesCents` | integer | yes |  |

### CreateOrderRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `quantity` | integer | no | 1–100,000; default `1` |
| `reference` | string or null | no | up to 200 characters; e.g. `"shopify-#1042"` |
| `address` | object or null | no | Where the order ships to, or where the work happens. Required for on-site work and for contracts that ship to end customers. |
| `address.name` | string | yes | 1–120 characters |
| `address.line1` | string | yes | 1–200 characters |
| `address.line2` | string or null | no | up to 200 characters; default `null` |
| `address.city` | string | yes | 1–100 characters |
| `address.region` | string | yes | 2–2 characters |
| `address.postalCode` | string | yes | 3–12 characters |
| `address.country` | string | no | one of `US`; default `"US"` |
| `scheduledFor` | string or null | no | When the work should happen. Required when the contract's deadline is `scheduled` (e.g. an on-site visit or a weekly pickup).; format date-time; e.g. `"2026-10-13T08:00:00-05:00"` |

### Order

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `contractId` | string | yes |  |
| `buyerId` | string | yes |  |
| `workshopId` | string | yes |  |
| `sequence` | integer | yes |  |
| `route` | string | yes | one of `primary`, `backup` |
| `reference` | string or null | yes |  |
| `quantity` | integer | yes |  |
| `address` | [ReceivingAddress](#receivingaddress) | yes |  |
| `scheduledFor` | string or null | yes | format date-time |
| `status` | string | yes | one of `routed`, `accepted`, `shipped`, `completed`, `failed`, `cancelled` |
| `dueBy` | string | yes | format date-time |
| `buyerPriceCents` | integer | yes |  |
| `workshopPayoutCents` | integer | yes |  |
| `proofs` | array of object | yes |  |
| `proofs[].id` | string | yes |  |
| `proofs[].orderId` | string | yes |  |
| `proofs[].kind` | string | yes | one of `tracking_number`, `photo`, `signature`, `document`, `video` |
| `proofs[].value` | string | yes | 1–2,000 characters |
| `proofs[].carrier` | string or null | yes |  |
| `proofs[].createdAt` | string | yes | format date-time |
| `createdAt` | string | yes | format date-time |
| `updatedAt` | string | yes | format date-time |
| `shippedAt` | string or null | yes | format date-time |
| `completedAt` | string or null | yes | format date-time |

### ReceivingAddress

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 1–120 characters |
| `line1` | string | yes | 1–200 characters |
| `line2` | string or null | no | up to 200 characters; default `null` |
| `city` | string | yes | 1–100 characters |
| `region` | string | yes | 2–2 characters |
| `postalCode` | string | yes | 3–12 characters |
| `country` | string | no | one of `US`; default `"US"` |

### ProofRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | yes | one of `tracking_number`, `photo`, `signature`, `document`, `video` |
| `value` | string | yes | A tracking number, or an https:// link for photos, videos and documents; 1–2,000 characters; e.g. `"9400111899223197428490"` |
| `carrier` | string or null | no | up to 60 characters; e.g. `"USPS"` |

### Me

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `account` | [Account](#account) | yes |  |
| `workshop` | [Workshop](#workshop) | yes |  |

### Account

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `kind` | string | yes | one of `buyer`, `workshop`, `operator` |
| `name` | string | yes |  |
| `email` | string or null | yes | format email |
| `createdAt` | string | yes | format date-time |

### Workshop

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `accountId` | string | yes |  |
| `name` | string | yes |  |
| `entity` | string | yes | one of `individual`, `business` |
| `description` | string | yes |  |
| `capabilities` | array of string | yes | each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture` |
| `certifications` | array of string | yes | each one of `nursery_license`, `fda_food_facility`, `food_handler`, `general_liability`, `background_checked`, `bonded` |
| `country` | string | yes | 2–2 characters |
| `region` | string | yes | up to 80 characters |
| `serviceArea` | string or null | yes |  |
| `postalCode` | string or null | yes |  |
| `receivingAddress` | [ReceivingAddress](#receivingaddress) | yes |  |
| `email` | string or null | yes |  |
| `phone` | string or null | yes |  |
| `weeklyCapacity` | integer or null | yes |  |
| `rating` | number | yes | 0–5 |
| `onTimeRate` | number | yes | 0–1 |
| `completedOrders` | integer | yes |  |
| `active` | boolean | yes |  |
| `source` | string | yes | one of `direct`, `application`, `outreach` |
| `createdAt` | string | yes | format date-time |

### SupportRequestRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | up to 254 characters; format email |
| `topic` | string | no | one of `buying`, `workshop`, `api`, `privacy`, `other`; default `"other"` |
| `message` | string | yes | 10–4,000 characters |
| `source` | string or null | no | up to 120 characters; default `null` |

### Invitation

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `tenderId` | string | yes |  |
| `title` | string | yes |  |
| `spec` | [Spec](#spec) | yes |  |
| `biddingClosesAt` | string or null | yes | format date-time |
| `maxBidCents` | integer or null | yes | Highest unit bid within the buyer's ceiling |
| `bid` | object or null | yes |  |
| `bid.id` | string | yes |  |
| `bid.tenderId` | string | yes |  |
| `bid.workshopId` | string | yes |  |
| `bid.unitPriceCents` | integer | yes |  |
| `bid.weeklyCapacity` | integer | yes |  |
| `bid.leadTimeDays` | integer or null | yes |  |
| `bid.note` | string or null | yes | up to 500 characters |
| `bid.portfolio` | array of string | yes | up to 6 items |
| `bid.status` | string | yes | one of `submitted`, `withdrawn`, `won`, `backup`, `lost` |
| `bid.createdAt` | string | yes | format date-time |
| `bid.updatedAt` | string | yes | format date-time |
| `clarifications` | array of [Clarification](#clarification) | yes | Answered questions (shared with every bidder) and your own open ones |

### BidRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `unitPriceCents` | integer | yes | Your net price per unit, before Établi's fee; e.g. `430` |
| `weeklyCapacity` | integer | yes | e.g. `300` |
| `leadTimeDays` | integer or null | no | 0–365 |
| `note` | string or null | no | up to 500 characters |
| `portfolio` | array of string | no | Up to six photo URLs of similar past work, shown to the buyer with your bid; up to 6 items; e.g. `["https://example.com/work/quarter-zip-embroidery.jpg"]` |

### Bid

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `tenderId` | string | yes |  |
| `workshopId` | string | yes |  |
| `unitPriceCents` | integer | yes |  |
| `weeklyCapacity` | integer | yes |  |
| `leadTimeDays` | integer or null | yes |  |
| `note` | string or null | yes | up to 500 characters |
| `portfolio` | array of string | yes | up to 6 items |
| `status` | string | yes | one of `submitted`, `withdrawn`, `won`, `backup`, `lost` |
| `createdAt` | string | yes | format date-time |
| `updatedAt` | string | yes | format date-time |

### AskClarificationRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `question` | string | yes | 3–1,000 characters; e.g. `"Is there parking on site, or should we plan for street parking?"` |

### WorkshopApplicationRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `entity` | string | yes | one of `individual`, `business` |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | format email |
| `description` | string | yes | 20–2,000 characters |
| `trades` | array of string | no | up to 12 items; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture`; default `[]` |
| `area` | string | yes | 2–80 characters |
| `country` | string | no | 2–2 characters; default `"US"` |
| `website` | string or null | no | up to 300 characters; default `null` |
| `weeklyCapacity` | integer or null | no | up to 100,000; default `null` |
| `note` | string or null | no | up to 1,000 characters; default `null` |

### BidLinkView

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `leg` | object | yes |  |
| `leg.title` | string | yes |  |
| `leg.role` | string | yes |  |
| `leg.spec` | [Spec](#spec) | yes |  |
| `projectTitle` | string | yes |  |
| `invitedAs` | string | yes |  |
| `maxBidCents` | integer or null | yes |  |
| `clarifications` | array of [Clarification](#clarification) | yes |  |
| `closesAt` | string or null | yes | format date-time |
| `status` | string | yes | one of `open`, `closed`, `bid_submitted`, `declined` |

### BidLinkBidRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business` | object | yes |  |
| `business.name` | string | yes | 2–160 characters |
| `business.entity` | string | yes | one of `individual`, `business` |
| `business.email` | string | yes | format email |
| `business.phone` | string or null | no | up to 40 characters; default `null` |
| `business.website` | string or null | no | up to 300 characters; default `null` |
| `business.country` | string | yes | 2–2 characters |
| `business.region` | string or null | no | up to 80 characters; default `null` |
| `business.city` | string or null | no | up to 80 characters; default `null` |
| `business.description` | string | yes | 20–2,000 characters |
| `unitPriceCents` | integer | yes |  |
| `weeklyCapacity` | integer | yes |  |
| `leadTimeDays` | integer or null | no | 0–365; default `null` |
| `note` | string or null | no | up to 500 characters; default `null` |
| `portfolio` | array of string | no | up to 6 items; default `[]` |

### BidLinkQuestionRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business` | object | yes |  |
| `business.name` | string | yes | 2–160 characters |
| `business.phone` | string or null | no | up to 40 characters; default `null` |
| `business.email` | string or null | no | format email; default `null` |
| `question` | string | yes | 3–1,000 characters |

### BidLinkDeclineRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `reason` | string or null | no | up to 1,000 characters; default `null` |

### WaitlistRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `email` | string | yes | format email |
| `role` | string | yes | one of `buyer`, `workshop` |
| `note` | string or null | no | up to 1,000 characters |

### OnboardWorkshopRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–120 characters |
| `entity` | string | no | one of `individual`, `business`; default `"business"` |
| `email` | string or null | no | Where Établi sends contracts and orders; also the account's email unless another account has it; format email |
| `description` | string | no | What they make or do, in their own words (embedded for matching); up to 2,000 characters |
| `capabilities` | array of string | yes | not empty; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture` |
| `certifications` | array of string | no | each one of `nursery_license`, `fda_food_facility`, `food_handler`, `general_liability`, `background_checked`, `bonded`; default `[]` |
| `country` | string | no | 2–2 characters; default `"US"` |
| `region` | string | yes | Two-letter state code in the US; province or region elsewhere; 1–80 characters |
| `serviceArea` | string or null | no | City covered for on-site work; up to 80 characters; e.g. `"Austin, TX"` |
| `postalCode` | string or null | no | up to 12 characters |
| `receivingAddress` | object or null | no | Where another leg's output is delivered to them (e.g. pots for a nursery) |
| `receivingAddress.name` | string | yes | 1–120 characters |
| `receivingAddress.line1` | string | yes | 1–200 characters |
| `receivingAddress.line2` | string or null | no | up to 200 characters; default `null` |
| `receivingAddress.city` | string | yes | 1–100 characters |
| `receivingAddress.region` | string | yes | 2–2 characters |
| `receivingAddress.postalCode` | string | yes | 3–12 characters |
| `receivingAddress.country` | string | no | one of `US`; default `"US"` |
| `phone` | string or null | no | up to 40 characters |
| `weeklyCapacity` | integer or null | no |  |

### OnboardedWorkshop

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workshop` | [Workshop](#workshop) | yes |  |
| `apiKey` | string | yes | Shown once. Give it to the workshop. |

### WorkshopApplication

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `entity` | string | yes | one of `individual`, `business` |
| `name` | string | yes | 2–120 characters |
| `email` | string | yes | format email |
| `description` | string | yes | 20–2,000 characters |
| `trades` | array of string | no | up to 12 items; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture`; default `[]` |
| `area` | string | yes | 2–80 characters |
| `country` | string | no | 2–2 characters; default `"US"` |
| `website` | string or null | no | up to 300 characters; default `null` |
| `weeklyCapacity` | integer or null | no | up to 100,000; default `null` |
| `note` | string or null | no | up to 1,000 characters; default `null` |
| `id` | string | yes |  |
| `status` | string | yes | one of `new`, `approved`, `declined` |
| `workshopId` | string or null | yes |  |
| `createdAt` | string | yes | format date-time |

### ApproveApplicationRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `capabilities` | array of string | no | Override the trades they picked; each one of `cleaning.home`, `cleaning.turnover`, `cleaning.office`, `laundry.linens`, `apparel.screen_printing`, `apparel.embroidery`, `apparel.dtf`, `plants.potting`, `plants.live_shipping`, `ceramics.production`, `plastics.molding`, `packaging.printing`, `labels.printing`, `engraving.laser`, `candles.pouring`, `cosmetics.filling`, `food.co_packing`, `food.catering`, `kitting.assembly`, `fulfillment.parcel`, `photography.product`, `field.photo_audit`, `returns.grading`, `property.maintenance`, `machining.cnc`, `electronics.assembly`, `data.video_capture` |
| `region` | string | no | State code in the US (read from their area when omitted); up to 80 characters |
| `serviceArea` | string or null | no | City covered for on-site work, e.g. Austin, TX; up to 80 characters |

### DeclineApplicationRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `reason` | string or null | no | up to 1,000 characters |

### QuoteRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `request` | string | yes |  |
| `answers` | array of [Answer](#answer) | yes |  |
| `plan` | [PlanPreview](#planpreview) | yes |  |
| `name` | string | yes |  |
| `email` | string | yes |  |
| `company` | string or null | yes |  |
| `note` | string or null | yes |  |
| `status` | string | yes | one of `new`, `quoted`, `closed` |
| `projectId` | string or null | yes |  |
| `createdAt` | string | yes | format date-time |

### SupportRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `email` | string | yes |  |
| `topic` | string | yes | one of `buying`, `workshop`, `api`, `privacy`, `other` |
| `message` | string | yes |  |
| `source` | string or null | yes |  |
| `status` | string | yes | one of `new`, `handled` |
| `createdAt` | string | yes | format date-time |
| `handledAt` | string or null | yes | format date-time |

### AdminOverview

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `counts` | object | yes |  |
| `counts.projects` | integer | yes |  |
| `counts.openLegs` | integer | yes |  |
| `counts.quoteRequestsNew` | integer | yes |  |
| `counts.workshops` | integer | yes |  |
| `counts.applicationsNew` | integer | yes |  |
| `counts.prospects` | integer | yes |  |
| `counts.outreachDrafts` | integer | yes |  |
| `counts.outreachAwaitingSend` | integer | yes |  |
| `counts.repliesToRead` | integer | yes |  |
| `counts.supportRequestsNew` | integer | yes |  |
| `recent` | array of [ActivityEvent](#activityevent) | yes |  |

### ActivityEvent

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `type` | string | yes |  |
| `subjectId` | string | yes |  |
| `accountId` | string or null | yes |  |
| `summary` | string | yes |  |
| `payload` | object or null | yes |  |
| `createdAt` | string | yes | format date-time |

### AdminProject

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `project` | [Project](#project) | yes |  |
| `buyer` | object or null | yes |  |
| `buyer.id` | string | yes |  |
| `buyer.name` | string | yes |  |
| `buyer.email` | string or null | yes |  |
| `contact` | object or null | yes |  |
| `contact.name` | string or null | no | 2–120 characters; default `null` |
| `contact.email` | string | yes | up to 254 characters; format email |
| `agentFamily` | string or null | yes | one of `claude_code`, `codex`, `chatgpt`, `claude`, `cursor`, `vscode`, `gemini`, `etabli_cli`, `browser`, `curl`, `python`, `node`, `other`, `null` |

### AgentActivity

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `families` | array of object | yes |  |
| `families[].family` | string | yes | one of `claude_code`, `codex`, `chatgpt`, `claude`, `cursor`, `vscode`, `gemini`, `etabli_cli`, `browser`, `curl`, `python`, `node`, `other` |
| `families[].label` | string | yes |  |
| `families[].last7Days` | [Last7Days](#last7days) | yes |  |
| `families[].last30Days` | [Last7Days](#last7days) | yes |  |
| `totals` | object | yes |  |
| `totals.last7Days` | [Last7Days](#last7days) | yes |  |
| `totals.last30Days` | [Last7Days](#last7days) | yes |  |
| `through` | string | yes | format date |

### Last7Days

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `previews` | integer | yes |  |
| `drafts` | integer | yes |  |
| `claims` | integer | yes |  |
| `quoteRequests` | integer | yes |  |

### LegMatch

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | yes | one of `workshop`, `prospect` |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `entity` | string or null | yes | one of `individual`, `business`, `null` |
| `country` | string or null | yes |  |
| `region` | string or null | yes |  |
| `similarity` | number | yes |  |
| `reasons` | array of string | yes |  |
| `invited` | boolean | yes |  |

### SourcingRun

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `prospects` | array of [Prospect](#prospect) | yes |  |
| `drafts` | array of [OutreachMessage](#outreachmessage) | yes |  |

### Prospect

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `website` | string or null | yes |  |
| `country` | string or null | yes | 2–2 characters |
| `region` | string or null | yes |  |
| `city` | string or null | yes |  |
| `description` | string | yes |  |
| `contacts` | array of object | yes |  |
| `contacts[].kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `contacts[].value` | string | yes | 1–500 characters |
| `contacts[].label` | string or null | yes | up to 120 characters |
| `source` | object | yes |  |
| `source.query` | string or null | yes |  |
| `source.url` | string or null | yes |  |
| `fit` | string or null | yes |  |
| `status` | string | yes | one of `new`, `contacted`, `interested`, `bid`, `declined`, `onboarded`, `do_not_contact` |
| `workshopId` | string or null | yes |  |
| `tenderIds` | array of string | yes |  |
| `notes` | string or null | yes |  |
| `createdAt` | string | yes | format date-time |

### OutreachMessage

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `prospect` | object | yes |  |
| `prospect.id` | string | yes |  |
| `prospect.name` | string | yes |  |
| `prospect.country` | string or null | yes |  |
| `tenderId` | string | yes |  |
| `projectId` | string or null | yes |  |
| `legTitle` | string | yes |  |
| `channelId` | string or null | yes |  |
| `to` | object or null | yes |  |
| `to.kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `to.value` | string | yes | 1–500 characters |
| `to.label` | string or null | yes | up to 120 characters |
| `subject` | string | yes |  |
| `body` | string | yes |  |
| `status` | string | yes | one of `draft`, `approved`, `sent`, `failed`, `rejected`, `replied` |
| `bidUrl` | string or null | yes |  |
| `error` | string or null | yes |  |
| `createdAt` | string | yes | format date-time |
| `approvedAt` | string or null | yes | format date-time |
| `sentAt` | string or null | yes | format date-time |

### ProspectRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | 2–160 characters |
| `website` | string or null | no | up to 300 characters; default `null` |
| `country` | string or null | no | 2–2 characters; default `null` |
| `region` | string or null | no | up to 80 characters; default `null` |
| `city` | string or null | no | up to 80 characters; default `null` |
| `description` | string | yes | 3–2,000 characters |
| `contacts` | array of object | no | up to 10 items; default `[]` |
| `contacts[].kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `contacts[].value` | string | yes | 1–500 characters |
| `contacts[].label` | string or null | yes | up to 120 characters |
| `tenderId` | string or null | no | default `null` |
| `notes` | string or null | no | up to 2,000 characters; default `null` |

### ProspectEditRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | no | 2–160 characters |
| `website` | string or null | no | up to 300 characters |
| `country` | string or null | no | 2–2 characters |
| `region` | string or null | no | up to 80 characters |
| `city` | string or null | no | up to 80 characters |
| `description` | string | no | 3–2,000 characters |
| `contacts` | array of object | no | up to 10 items |
| `contacts[].kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `contacts[].value` | string | yes | 1–500 characters |
| `contacts[].label` | string or null | yes | up to 120 characters |
| `fit` | string or null | no | up to 1,000 characters |
| `status` | string | no | one of `new`, `contacted`, `interested`, `bid`, `declined`, `onboarded`, `do_not_contact` |
| `notes` | string or null | no | up to 2,000 characters |
| `tenderId` | string | no |  |

### OutreachMessageEditRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `subject` | string | no | 1–200 characters |
| `body` | string | no | 1–10,000 characters |
| `channelId` | string or null | no |  |
| `to` | object or null | no |  |
| `to.kind` | string | yes | one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |
| `to.value` | string | yes | 1–500 characters |
| `to.label` | string or null | yes | up to 120 characters |

### OutreachReply

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `messageId` | string | yes |  |
| `body` | string | yes |  |
| `classification` | string | yes | one of `interested`, `declined`, `question`, `other` |
| `summary` | string | yes |  |
| `createdAt` | string | yes | format date-time |

### OutreachReplyRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `body` | string | yes | 1–20,000 characters; e.g. `"Yes, we can do lots of 100."` |

### OutreachChannel

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `kind` | string | yes | one of `email`, `manual` |
| `name` | string | yes |  |
| `settings` | [Settings](#settings) | yes |  |
| `hasSecret` | boolean | yes |  |
| `active` | boolean | yes |  |
| `createdAt` | string | yes | format date-time |

### Settings

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `fromAddress` | string or null | yes | up to 200 characters |
| `replyTo` | string or null | yes | up to 200 characters |
| `instructions` | string or null | yes | up to 2,000 characters |
| `contactKinds` | array of string | yes | not empty; each one of `email`, `phone`, `website`, `contact_form`, `alibaba`, `linkedin`, `other` |

### OutreachChannelRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | yes | one of `email`, `manual` |
| `name` | string | yes | 2–80 characters |
| `settings` | [Settings](#settings) | yes |  |
| `secret` | string or null | no | 1–500 characters |
| `active` | boolean | no | default `true` |

### OutreachChannelEditRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `kind` | string | no | one of `email`, `manual` |
| `name` | string | no | 2–80 characters |
| `settings` | [Settings](#settings) | no |  |
| `secret` | string or null | no | 1–500 characters |
| `active` | boolean | no |  |
