# UFP vendor binding — the vendor-hosted HTTP surface

**Any fabrication vendor can sell to agents by hosting five endpoints: a
discovery document and four API routes.** No marketplace signup, no
proprietary integration — a vendor that speaks this binding can be quoted
by any UFP network (which fans out, ranks, and carries the order) or by an
agent directly. The binding is deliberately small: quote, order, track.

The canonical spec is code:
[`packages/protocol/src/vendor-api.ts`](../packages/protocol/src/vendor-api.ts)
(Zod schemas). This document is the human-readable rendering. For the
agent-facing network surface (the `/v1/*` funnel), see
[PROTOCOL.md](PROTOCOL.md); for reputation records see
[REPUTATION.md](REPUTATION.md); for reporting off-network sales see
[ATTESTATIONS.md](ATTESTATIONS.md).

## Discovery

A caller finds a vendor by fetching `/.well-known/ufp.json` from the
vendor's origin:

```jsonc
// GET https://acme.example/.well-known/ufp.json
{
  "protocol": "ufp",
  "versions": ["0.2"],                     // major.minor, newest first
  "base_url": "https://acme.example/ufp/v1",  // all endpoints live under this
  "vendor": { "slug": "acme-metal", "display_name": "Acme Metal Works" },
  "auth": {                                // omit entirely = quoting is keyless
    "scheme": "bearer",                    //   (orders may still require auth)
    "signup_url": "https://acme.example/ufp/keys"
  },
  "optional_endpoints": ["price-checks", "health"],
  "attestation_networks": [                // plural — UFP is network-neutral:
    "https://api.network-a.example",       // the networks this vendor reports
    "https://api.network-b.example"        // off-network sales to (ATTESTATIONS.md)
  ],
  "sandbox": false                         // true = orders never spend money
}
```

## Conventions

- **`UFP-Version` header.** Vendors stamp it on every response; callers
  SHOULD send it on every request. Compatibility is same-`major.minor`: a
  request carrying a version the vendor doesn't serve gets HTTP 426 with
  `supported_versions` in the body. An absent header means the caller
  takes what it gets.
- **Unknown fields are ignored, never rejected.** Readers MUST tolerate
  fields they don't recognize, in both directions — the protocol evolves
  additively without version bumps.
- **Money is integer USD cents.** No floats, no currency strings inside
  amounts.
- **Durations, not dates.** Vendors quote `production_business_days` and
  `transit_business_days`; the CALLER computes calendar ETAs. A vendor
  never promises a date it can't control the calendar for.
- **Files travel as descriptors, not bytes.** A design file is a
  caller-hosted URL plus its SHA-256 (`FileDescriptor`), fetchable for the
  lifetime of the exchange. The vendor MUST verify the `sha256` after
  fetching — hashes cross process boundaries, bytes don't.
- **File URLs are opaque and possibly private.** A caller MAY mint a
  different URL per vendor per exchange, and URLs MAY be single-recipient,
  expiring, and access-logged — regulated designs (export-controlled,
  medical) will require exactly that. Vendors MUST fetch when needed
  rather than store the URL, and MUST NOT cache it beyond the exchange,
  share it, or embed it in anything durable (order records, emails,
  tickets). The `sha256` — not the URL — is the file's identity; keep the
  hash, re-request the file. Implementations built on this rule work
  unchanged when a network turns on scoped file grants.

### Error contract

Errors are `{ "code": "...", "message?": "...", "supported_versions?": [...] }`:

| Status | `code`                                        | When                                                                 |
| ------ | --------------------------------------------- | -------------------------------------------------------------------- |
| 400    | `invalid_spec` / `invalid_request`             | Body isn't valid JSON or fails the wire schema                        |
| 401    | `unauthorized`                                 | Missing/wrong bearer token (when the discovery doc advertises auth)   |
| 404    | `unknown_order` / `unsupported_endpoint` / `not_found` | Ref the vendor never issued; an optional endpoint not implemented |
| 422    | `unsupported_process`                          | Process this vendor doesn't offer — **never a 500**                   |
| 426    | `version_unsupported`                          | `UFP-Version` major.minor mismatch; body lists `supported_versions`   |
| 500    | `internal`                                     | Vendor bug. Generic body only — never a stack trace on the wire       |

## Endpoint reference

All paths relative to the discovery doc's `base_url`:

| Method & path        | Required | Purpose                                        |
| -------------------- | -------- | ---------------------------------------------- |
| `GET /.well-known/ufp.json` (origin) | required | Discovery: versions, auth, optional surface |
| `GET  /capabilities` | required | What can you make? (materials, ranges, claims) |
| `POST /quotes`       | required | Priced offers for a request                    |
| `POST /orders`       | required | Place an order (idempotent)                    |
| `GET  /orders/:ref`  | required | Order status + tracking evidence               |
| `POST /price-checks` | optional | Live-confirm one configuration (firm-price vendors skip it) |
| `POST /previews`     | optional | Render a product preview                       |
| `GET  /health`       | optional | Liveness probe (never requires auth)           |

### `GET /capabilities`

Returns the vendor's own capability document — same shape as a network's
`GET /v1/capabilities` but for one vendor (the per-process `vendors` field
is omitted). Attribute claims here follow the honest-claims rule: only
flags the vendor actually advertises appear; absent means "no claim",
never "no". Claims are an **open vocabulary**: beyond the named flags
(`outdoor_rated`, `submersible`, `high_temp`, `food_safe`), a capability
sheet may claim any boolean key — `"iso_13485": true`, `"anodized": true`
— under the same honest-claims rule; no schema change needed. A process
entry may also claim `tolerance_mm`, the tightest ± tolerance (in mm) the
vendor holds for that process. Absent means no claim — and buyers who
demand a tolerance exclude vendors with no claim on record, which is both
honest and a nudge to publish one.

### `POST /quotes` — a realistic exchange

`spec` is the per-process spec document (see PROTOCOL.md), minus
network-only fields (`file_id` / `part_number` / `share_key`) — the design
arrives as `file`. Recommended response budget: 20 seconds.

Requests MAY also carry `attachments` — supporting files for the same
part (the drawing PDF, a spec sheet, a BOM), each a `FileDescriptor` plus
an optional `role` (`drawing | spec_sheet | bom | centroid | aux`).
Vendors MAY use them — a shop that reads the drawing wins work a shop
that ignores it doesn't — and **ignoring them entirely is conformant**.
The same opaque-URL rules as `file` apply to every attachment URL. Order
requests re-send `attachments` (quote-time URLs may have expired).

```jsonc
// POST https://acme.example/ufp/v1/quotes
{
  "process": "sheetmetal",
  "spec": {
    "material": "galvanized steel",     // free-form — pass the buyer's words
    "thickness_in": 0.06,
    "bend_count": 2,
    "quantities": [3]
  },
  "file": {
    "url": "https://files.caller.example/f_8XwK/bracket.dxf",
    "sha256": "3fd51a09b7…64 hex chars…",   // vendor MUST verify after fetching
    "filename": "bracket.dxf",
    "mime": "image/vnd.dxf",
    "kind": "dxf",
    "size_bytes": 48210
  },
  "attachments": [                          // OPTIONAL supporting files (MAY use; ignoring
    { "url": "https://files.caller.example/f_9Yt2/bracket-drawing.pdf",  // is conformant) —
      "sha256": "8ab4429c11…", "filename": "bracket-drawing.pdf",        // same descriptor +
      "mime": "application/pdf", "kind": "artwork",                      // opaque-URL rules
      "size_bytes": 102400, "role": "drawing" }                          // as `file`
  ],
  "constraints": { "outdoor_rated": true }  // partial constraints; narrow, never widen
}
```

The response demonstrates the three semantics every vendor MUST implement
— minimum bumps, substitutions, honest attributes. **A merely-unusual ask
is never an error:**

```jsonc
{
  "offers": [
    {
      "offer_ref": "smq_7741_a",           // vendor's handle — passed back at order time
      "process": "sheetmetal",
      "spec_resolved": {                   // exactly what will be made
        "material": "mild_steel", "thickness_in": 0.063,
        "bend_count": 2, "finish": "powder_coat"
      },
      "quantity": 10,                      // MINIMUM BUMP: asked for 3, shop minimum
      "requested_quantity": 3,             // is 10 — quantity rises, the ask survives
      "substitutions": [                   // SUBSTITUTION: galvanized isn't stocked;
        { "field": "material",             // closest match provided, never a rejection
          "requested": "galvanized steel", "provided": "mild_steel" }
      ],
      "unit_price_cents": 2350,
      "subtotal_cents": 23500,
      "currency": "usd",
      "production_business_days": 5,       // durations — the caller computes ETAs
      "shipping_options": [
        { "id": "ground",  "label": "UPS Ground",  "cost_cents": 1400, "transit_business_days": 4 },
        { "id": "express", "label": "UPS 2nd Day", "cost_cents": 3900, "transit_business_days": 2 }
      ],
      "attributes": { "outdoor_rated": true },  // HONEST ATTRIBUTES: only claims this
                                                // vendor advertises; powder-coated mild
                                                // steel carries one, bare would not
      "price_basis": "firm",               // firm = honored at order time;
                                           // estimate = confirm via /price-checks
      "expires_at": "2026-07-12T18:00:00Z"
    }
  ],
  "notes": [
    "galvanized steel is not stocked — quoting powder-coated mild steel as the closest outdoor-rated match",
    "thickness 0.06 snapped to nearest stocked gauge (0.063 in / 16 ga)"
  ]
}
```

### Requirements & callouts

Quote requests may carry `constraints.requirements` — up to 20 verbatim
buyer terms ("UV resistant", "anodized: red", "iso 13485") — and
`constraints.callouts` — structured drawing dimensions `{name, value,
unit ("mm"|"in"), tolerance_mm?, critical?, note?}`. Vendors receive both
**verbatim** and MAY use them: quote an anodizing line for
"anodized: red", or decline a job whose callout tolerance the shop can't
hold. **Ignoring them entirely is conformant** — the caller applies its
own resolution and never depends on the vendor reading the channel.

A worked example — the buyer asked for `"anodized: red"` and this vendor
runs an anodizing line, so the term is honored in the resolved spec:

```jsonc
// POST /quotes (excerpt)
{
  "process": "cnc",
  "spec": { "material": "aluminum 6061", "quantities": [10] },
  "constraints": { "requirements": ["anodized: red", "iso 13485"] }
}

// -> response (excerpt)
{
  "offers": [{
    "spec_resolved": { "material": "aluminum_6061", "finish": "anodized_red" },
    "attributes": { "anodized": true },   // open-vocabulary claim, honest as ever
    "unit_price_cents": 4150
  }],
  "notes": ["'iso 13485' not applied — this shop holds no ISO 13485 certification"]
}
```

The pattern is the same as everywhere in the binding: a term the vendor
can't honor is **never an error** — quote what you can make, and say what
you couldn't apply in `notes`.

### `POST /orders`

```jsonc
{
  "idempotency_key": "caller_ord_9f2c1a77",   // MUST: replaying this key returns the
  "offer_ref": "smq_7741_a",                  // same vendor_order_ref, never a duplicate
  "quote": {                                  // (vendors SHOULD honor recently expired refs)
    "process": "sheetmetal",
    "spec_resolved": { "material": "mild_steel", "thickness_in": 0.063,
                       "bend_count": 2, "finish": "powder_coat" },
    "quantity": 10
  },
  "file": { "url": "…", "sha256": "3fd51a09b7…", "filename": "bracket.dxf",
            "mime": "image/vnd.dxf", "kind": "dxf", "size_bytes": 48210 },
  "shipping_option_id": "ground",
  "ship_to": { "name": "R. Chen", "street1": "500 W 2nd St", "city": "Austin",
               "state": "TX", "zip": "78701", "country": "US" },
  "contact": { "email": "orders@caller.example" },
  "payment": { "method": "on_account" },      // invoice | on_account — settlement is
                                              // OUT OF BAND in v0.2 (see below)
  "status_webhook_url": "https://caller.example/webhooks/vendor-status"  // optional
}
// -> 201 { "vendor_order_ref": "SM-100482", "status": "placed" }
//    ("accepted_manual" = a human at the vendor completes placement)
```

**Settlement is out of band in v0.2.** `payment.method` declares how money
moves — `invoice` or a pre-arranged `on_account` relationship — but no
funds move over the protocol. Agentic payment mandates (a signed,
spend-capped authorization traveling with the order) are the v0.3 path.

**Webhooks are optional; polling is the baseline.** If the order carried
`status_webhook_url`, the vendor POSTs a `VendorStatusWebhook`
(`{vendor_order_ref, status, tracking?, at}`) on each status change.
Callers MUST still be able to poll `GET /orders/:ref` — webhook delivery
is best-effort.

### `GET /orders/:ref`

```jsonc
{
  "vendor_order_ref": "SM-100482",
  "status": "shipped",       // placed | in_production | shipped | delivered | cancelled | unknown
  "tracking": { "available": true, "carrier": "ups", "number": "1Z999AA10123456784" },
  "evidence": {              // see the evidence model in ATTESTATIONS.md
    "tracking": { "available": true, "carrier": "ups", "number": "1Z999AA10123456784" },
    "file_sha256": "3fd51a09b7…",
    "invoice_ref": "INV-2214"
  },
  "updated_at": "2026-07-17T15:04:00Z"
}
```

A terminal `shipped` SHOULD carry tracking **or an explicit
declared-untracked marker** — `{ "available": false, "reason":
"untracked_shipping" }` is honest; an omitted field is not. The schema
never requires it (a missing tracking number can never break an
integration), but conformance checks it and networks grade reputation
credit by it.

## SDK quickstart

`@ufp/sdk` ships the binding as code: implement the `VendorService`
interface (parsed wire types in, wire types out) and `createVendorApp`
wraps it in the HTTP binding — routing, request/response validation,
versioning, the error contract, and bearer auth all handled. The result
is a plain fetch-standard handler: mount it on Hono (`app.mount`),
Cloudflare Workers (`export default { fetch }`), Bun.serve, or Node via
any Request/Response adapter.

```ts
import { createVendorApp, type VendorService } from "@ufp/sdk";

const service: VendorService = {
  slug: "acme-metal",
  displayName: "Acme Metal Works",
  capabilities: () => [{
    process: "sheetmetal",
    materials: [{ id: "mild_steel", label: "Mild steel" }],
    quantity_range: { min: 10, max: 5000 },
    file_types: ["image/vnd.dxf"],
    ships_to: ["US"],
  }],
  async quote(req) { /* price it: bump minimums, substitute closest matches */ },
  async placeOrder(req) { /* MUST be idempotent on req.idempotency_key */ },
  async getStatus(ref) { /* null = unknown ref -> the binding answers 404 */ },
};

export default {
  fetch: createVendorApp(service, {
    baseUrl: "https://acme.example/ufp/v1",
    attestationNetworks: ["https://api.network-a.example"],
  }),
};
```

The discovery document, optional-endpoint advertisement, and version
stamping are derived from the service and options — implement
`priceCheck`/`renderPreview`/`health` and they appear in
`optional_endpoints` automatically. A complete runnable vendor is
[`packages/examples/vendor-minimal`](../packages/examples/vendor-minimal).

## Conformance

The conformance kit lives at
[`packages/conformance`](../packages/conformance) and runs against any
live endpoint. **L1** checks read-only wire conformance: discovery doc,
capabilities, quote semantics (minimum bumps, substitutions, honest
attributes), and the error contract (422/426). **L2** adds the order
lifecycle against a sandbox endpoint: idempotent replay, status
progression, and evidence/tracking discipline.

```
npx @ufprotocol/conformance https://acme.example --level 2
```

**Passing L2 is the listing bar**: the reference network lists any vendor
whose endpoint passes L2, and other networks are encouraged to publish
the same requirement.
