# Rails Sandbox **Rails Sandbox** is a faithful mock of the public `2026-01` Integrations API for collision-repair parts procurement — the same RPC-over-POST wire shape, OAuth install flow, `Authorization: Bearer` + `Partly-Integration-ID` headers, and HMAC-SHA256 signed webhooks — served together with these docs as one deploy. It runs a real, seeded collision-repair scenario (a 2019 Toyota Corolla front-end job) so you can drive the whole lifecycle, from opening a job to a confirmed order, against live endpoints with no setup. Two clearly-labelled extensions (`place_procurement`, `reconcile_invoice`) carry the buyer loop one step past the public contract; everywhere they appear they are marked as proposed extensions, never passed off as the real `2026-01` contract. This is not a real vendor's own documentation. It is a Rails-*compatible* sandbox: a reference build that mirrors a real developer experience so you can prototype against a stable contract. ## Built for two kinds of reader - **Human developers** building an integration by hand — read the prose, copy the curl, ship. - **Coding agents** building an integration from these docs *alone* — every page is also served as raw markdown, every example is copy-paste-correct against the deployed sandbox, and every fact needed to complete an integration is reachable from [`/llms.txt`](/llms.txt). The same content serves both. There is no separate "agent" content fork — the markdown source *is* the page. ## Start here 1. **[Quickstart](./quickstart.md)** — zero to a confirmed procurement in copy-paste steps, including obtaining sandbox credentials programmatically via the OAuth install flow (no dashboard, no human step). 2. **[Authentication](./authentication.md)** — the install flow, the two required headers, pre-loaded demo credentials, and every coded auth/install failure. 3. **[Lifecycle](./lifecycle.md)** — the procurement spine: which wire method (and MCP tool) drives each state transition, ending at `order_confirmed`. 4. **[Webhooks](./webhooks.md)** — the signed-event envelope, the 5-minute replay window, dedup semantics, and complete HMAC-SHA256 verification code in TypeScript and Python. 5. **[Errors](./errors.md)** — every error kind: its wire shape, meaning, retryability, and the prescribed recovery. Written to be read by LLMs as well as humans. 6. **[API Reference](./api-reference.md)** — generated from the OpenAPI spec: every method's request/response schema, a runnable example, and every error it can return. 7. **[For AI Agents](./for-ai-agents.md)** — connect the deployed MCP servers (the runtime Rails MCP and the docs MCP) — or a local stdio install — and drive the lifecycle live. ## The procurement lifecycle, in one line `prepare_vehicle → open_repair_job → identify_parts → recommend_basket → place_procurement` ⚠ `→ confirm_procurement → reconcile_invoice` ⚠ the public `2026-01` lifecycle terminates at `order_confirmed` (the result of `confirm_procurement`). The two steps marked ⚠ — `place_procurement` and `reconcile_invoice` — are proposed extensions beyond that contract; see [Lifecycle](./lifecycle.md). Re-read state with `get_job` / `track_procurement` rather than assuming it. ## Machine-readable surfaces Built for agents and tooling, not just browsers: - **[`/llms.txt`](/llms.txt)** — the llms.txt index: one line and a link per page. The acceptance bar is that everything needed to build an integration is reachable from here alone. - **[`/llms-full.txt`](/llms-full.txt)** — every page's markdown, concatenated into one document. - **[`/openapi.json`](/openapi.json)** — the `2026-01` OpenAPI spec at a stable URL; the API Reference is generated from it. - **Per-page markdown** — append `.md` to any page URL, or send `Accept: text/markdown`, to get the raw source markdown instead of rendered HTML. No lossy conversion — it is the same file these docs are built from. ## One call to confirm it's live The install endpoint is the only unauthenticated method, so it needs no credentials. This rejects an unknown client (the expected failure for placeholder values) — proof the contract surface is up: ```bash curl -s -X POST \ https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/integrations.insert \ -H "Content-Type: application/json" \ -d '{"client_id":"unknown","client_secret":"unknown","access_code":"unknown"}' # -> {"type":"integration_not_found"} (HTTP 404) ``` Every call follows this convention: `POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/` with a JSON body. Authenticated methods additionally require `Authorization: Bearer ` and `Partly-Integration-ID: ` — the [Quickstart](./quickstart.md) hands you both. ## Fidelity & honesty Faithful to the public `2026-01` contract; lightweight in implementation, not in contract shape. Two things to keep in mind, stated wherever they matter: - **No `currency` on the basket.** The basket response (`recommend_basket` / `repairer.jobs.baskets.latest.get`) carries **no `currency` field** — currency is a procurement-level concept that appears later on the procurement and invoice (D52). Any tool that shows a currency on a basket offer has derived it as a presentation-layer label, not read it off the wire. - **`place_procurement` and `reconcile_invoice` are proposed extensions** beyond the `2026-01` API — the public contract stops at `order_confirmed`. They are labelled as such everywhere they appear. Where this sandbox simplifies versus a real production system, the relevant page says so. --- # Quickstart Go from nothing to a **confirmed parts order** in **under five minutes** — no signup, no dashboard, no waiting on a sales call. Credentials are obtained **programmatically** in the first call, and a fully-seeded collision-repair job is already loaded, so every step below is copy-paste runnable against the deployed mock. > This is **Rails Sandbox** — a Rails-compatible API: a faithful mock of the public Integrations API (`2026-01`), plus two clearly-labelled **proposed extensions** (steps 5 and 7). It is not a real vendor's own documentation and not a real production endpoint. **Calling convention.** The API is RPC-over-POST. Every call is: ```text POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/ ``` with a JSON body. The dotted method name is the final path segment (e.g. `.../api/2026-01/repairer.jobs.get`). All examples below assume `bash` + [`jq`](https://jqlang.github.io/jq/). Steps 1–4 and 6 are **faithful** to the public `2026-01` contract; steps 5 and 7 are **proposed extensions** (each marked below). --- ## 0. Pick your sandbox This sandbox gives every distinct `x-partifact-tenant` value its **own fresh, isolated copy** of the world — seeded identically (the same Corolla job, the same demo credentials), self-healing after an hour of inactivity. Pick any name and send it as a header on **every** call below, and your Quickstart runs in private — the install in step 1 is guaranteed to work for you even if a hundred other people ran it first. ```bash BASE=https://partifact-mock-rails.thanhvuttv.workers.dev TENANT="quickstart-$(whoami)" # any [a-z0-9_-] name; your own private sandbox ``` > `x-partifact-tenant` is a **sandbox convenience for isolation**, not part of the contract — a real the tenant boundary is the organization. Omit the header and you share one base world with everyone else (where the single-use access code in step 1 may already be spent). To start over from a clean seed, just change `TENANT`. --- ## 1. Install — get credentials programmatically `integrations.insert` is the **only** unauthenticated method: it completes the OAuth install handshake and mints your `api_key` + `integration_id`. The demo OAuth client and its access code are pre-provisioned in every fresh sandbox, so you can run this verbatim. ```bash INSTALL=$(curl -sS -X POST "$BASE/api/2026-01/integrations.insert" \ -H "x-partifact-tenant: $TENANT" \ -H "Content-Type: application/json" \ -d '{"access_code":"ac_demo_valid_15m","client_id":"partly_client_demo","client_secret":"secret_demo_8f3a"}') API_KEY=$(echo "$INSTALL" | jq -r .api_key) INTEGRATION_ID=$(echo "$INSTALL" | jq -r .integration_id) echo "api_key=$API_KEY" echo "integration_id=$INTEGRATION_ID" ``` Exchanges `{access_code, client_id, client_secret}` for long-lived, org-scoped credentials. Expected response shape: ```json { "api_key": "partly_6bf45534812d4a0ea5a0dd700585caf3", "integration_id": "b07982d2-b41d-428a-81cc-596f99d2604f" } ``` The `api_key` is freshly minted (prefix `partly_` followed by a UUID with dashes stripped); the `integration_id` is a fresh UUID. **The access code is single-use per sandbox** — after a successful install, `ac_demo_valid_15m` is consumed in *this* `TENANT`, and re-running install here returns `{"type":"invalid_access_code"}` (HTTP 401). That is expected, not a bug — change `TENANT` for a fresh code. > **Prefer to skip the install?** Every sandbox also pre-loads a ready-to-use **repairer** credential you can drop straight into the headers below (step 6 uses the matching **supplier** credential): > ```bash > API_KEY=partly_demo_repairer_3f8a1c0d9e2b4a67b1c2 > INTEGRATION_ID=0c000000-0000-4000-8000-000000000001 > ``` --- ## 2. The two headers on every authenticated call Every method except `integrations.insert` requires **both** of these headers: ```text Authorization: Bearer Partly-Integration-ID: ``` The `Bearer ` scheme prefix is required. If either header is missing or the `(api_key, integration_id)` pair is unknown, the call returns `{"type":"unauthorized"}` (HTTP 401). If the credential is valid but holds the wrong **role** for the method, you get `{"type":"forbidden"}` (HTTP 403) — see [Authentication](./authentication.md) for the scope model. From here on, every call carries these two headers **and** your `x-partifact-tenant`. --- ## 3. Read the pre-seeded job A complete collision-repair job is already loaded — a **2019 Toyota Corolla** (front-end collision) with `external_id` **`CCC-2026-04817`**. No setup required. Fetch it by external reference (the identity wrapper is `oneOf` `{external}` or `{id}` — send exactly one; note the key is `external`, not `external_id`): ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.get" \ -H "x-partifact-tenant: $TENANT" \ -H "Authorization: Bearer $API_KEY" \ -H "Partly-Integration-ID: $INTEGRATION_ID" \ -H "Content-Type: application/json" \ -d '{"identity":{"external":"CCC-2026-04817"}}' ``` Returns the **job header** — vehicle, site, insurer, claim. Expected response shape: ```json { "id": "0d000000-0000-4000-8000-000000000001", "external_id": "CCC-2026-04817", "job_number": "J-4817", "claim_number": "AMI-CLM-771204", "repairer_site_id": "0b000000-0000-4000-8000-000000000001", "work_provider_id": "0a000000-0000-4000-8000-000000000020", "vehicle": { "chassis_number": "JTDBR32E730012345", "plate_number": "MJL472", "plate_state": "NZ" }, "images": [] } ``` The wire `repairer.jobs.get` returns the **header only** — it does **not** embed parts, procurements, or invoices. The job's parts are priced in the basket (next step), where each offer references the `job_part_ids` it fulfils. --- ## 4. Get the recommended basket `repairer.jobs.baskets.latest.get` returns the latest priced basket for the job: the recommended `offers` and the `suppliers` they reference. Note the request key here is `job_identity` (not `identity`). ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.baskets.latest.get" \ -H "x-partifact-tenant: $TENANT" \ -H "Authorization: Bearer $API_KEY" \ -H "Partly-Integration-ID: $INTEGRATION_ID" \ -H "Content-Type: application/json" \ -d '{"job_identity":{"external":"CCC-2026-04817"}}' ``` Read-only; a job with no priced parts returns empty `offers`/`suppliers` (not an error). Expected response shape (two of the twelve seeded offers shown): ```json { "offers": [ { "name": "Toyota Genuine Headlamp RH", "sellable_id": "0f000000-0000-4000-8000-000000000007", "supplier_business_id": "0b000000-0000-4000-8000-000000000010", "job_part_ids": ["0e000000-0000-4000-8000-000000000004"], "quantity": 1, "trade_price": "631.00", "condition": { "type": "new", "payload": {} } }, { "name": "Toyota Genuine Radiator Support", "sellable_id": "0f000000-0000-4000-8000-00000000000a", "supplier_business_id": "0b000000-0000-4000-8000-000000000010", "job_part_ids": ["0e000000-0000-4000-8000-000000000006"], "quantity": 1, "trade_price": "468.00", "condition": { "type": "new", "payload": {} } } ], "suppliers": [ { "business_id": "0b000000-0000-4000-8000-000000000010", "name": "Christchurch Toyota — Parts" }, { "business_id": "0b000000-0000-4000-8000-000000000011", "name": "Repco Trade — Christchurch" }, { "business_id": "0b000000-0000-4000-8000-000000000012", "name": "PartsMaster Aftermarket NZ" } ] } ``` > **Wire-truth note (D52).** The basket response carries **no `currency` field** — not on the offer, not at the envelope. `trade_price` is a bare string. **Currency is a procurement-level concept**, not a basket-level one: it first appears later, as `currency_code` on the procurement (and on the invoice). Any tool that shows a currency on a basket offer is deriving it for presentation, not reading it off the wire. The seed prices are NZD ex-GST. The two offers above both come from **`Christchurch Toyota — Parts`** (`supplier_business_id` `0b000000-0000-4000-8000-000000000010`). That matters for the next step. --- ## 5. Place the order > **(Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.)** `repairer.procurements.insert` creates a procurement from selected basket offers in `status: "order_requested"` and moves the selected parts `estimated → ordered`. This method is **not part of the public `2026-01` contract** — its response carries `"x_extension": true` to mark it as a proposed extension. **Pick offers from one confirmable supplier.** The sandbox holds exactly one supplier credential — for **`Christchurch Toyota — Parts`** (`business_id 0b000000-0000-4000-8000-000000000010`). The raw wire will let you place an order with *any* supplier, but only an order placed **entirely** with Christchurch Toyota can be confirmed in step 6 — no other supplier has a confirming credential, so a mismatch surfaces as `not_found` at confirm time and the order is left stranded in `order_requested`. (The companion [Rails MCP](./for-ai-agents.md) adds a guard that refuses a non-confirmable placement up front; the raw wire does not.) So the two Christchurch Toyota offers from step 4 are selected here: ```bash PLACE=$(curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.insert" \ -H "x-partifact-tenant: $TENANT" \ -H "Authorization: Bearer $API_KEY" \ -H "Partly-Integration-ID: $INTEGRATION_ID" \ -H "Content-Type: application/json" \ -d '{ "job_identity": {"external": "CCC-2026-04817"}, "offer_selections": [ { "sellable_id": "0f000000-0000-4000-8000-000000000007", "supplier_business_id": "0b000000-0000-4000-8000-000000000010", "job_part_ids": ["0e000000-0000-4000-8000-000000000004"], "quantity": 1 }, { "sellable_id": "0f000000-0000-4000-8000-00000000000a", "supplier_business_id": "0b000000-0000-4000-8000-000000000010", "job_part_ids": ["0e000000-0000-4000-8000-000000000006"], "quantity": 1 } ] }') PROCUREMENT_ID=$(echo "$PLACE" | jq -r .procurement.id) echo "procurement_id=$PROCUREMENT_ID" echo "$PLACE" | jq '{x_extension, id: .procurement.id, status: .procurement.status, currency_code: .procurement.currency_code, supplier: .procurement.supplier_business.name}' ``` Creates the order and captures its id. Expected response shape (abridged): ```json { "x_extension": true, "procurement": { "id": "3263c65c-d458-410b-a5f0-df2b51c16a17", "job_id": "0d000000-0000-4000-8000-000000000001", "status": "order_requested", "currency_code": "NZD", "supplier_business": { "id": "0b000000-0000-4000-8000-000000000010", "name": "Christchurch Toyota — Parts" } } } ``` The procurement is now in `order_requested` — the RH headlamp (`631.00`) plus the radiator support (`468.00`) total **`1099.00` NZD**. The created procurement is immediately retrievable via the real `repairer.procurements.get` and confirmable via the real `supplier.procurements.confirm` — the extension only adds the missing "place" front door; it never alters the real state machine. --- ## 6. Confirm the order → `order_confirmed` `supplier.procurements.confirm` is the **faithful** terminal step of the public `2026-01` lifecycle: it transitions the procurement `order_requested → order_confirmed`, locks the order, and creates a reconciled invoice. Confirming is a **supplier-scope** action, so it needs the pre-loaded **supplier** credential (not the repairer one used above) — same sandbox, different headers: ```bash SUP_API_KEY=partly_demo_supplier_8b4e2f1a6c0d3e9f7a25 SUP_INTEGRATION_ID=0c000000-0000-4000-8000-000000000002 curl -sS -X POST "$BASE/api/2026-01/supplier.procurements.confirm" \ -H "x-partifact-tenant: $TENANT" \ -H "Authorization: Bearer $SUP_API_KEY" \ -H "Partly-Integration-ID: $SUP_INTEGRATION_ID" \ -H "Content-Type: application/json" \ -d "{\"identity\":{\"id\":\"$PROCUREMENT_ID\"}}" ``` Confirms by procurement id, as the supplier. Expected response shape (abridged): ```json { "id": "3263c65c-d458-410b-a5f0-df2b51c16a17", "status": "order_confirmed" } ``` `order_confirmed` is the **terminal state of the public `2026-01` contract** — the faithful lifecycle ends here. A supplier can only confirm its own orders; confirming an order placed with a different supplier returns `{"type":"not_found"}` (which is exactly why step 5 selected `Christchurch Toyota — Parts`). You have now driven a parts order from zero to confirmed. > **Want to confirm without placing first?** Every sandbox also ships one procurement already in `order_requested` (id `10000000-0000-4000-8000-000000000001`) — substitute that id above to exercise confirm on its own. --- ## 7. (Optional) Reconcile the invoice > **(Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.)** `repairer.procurements.invoices.list` reads the reconciled invoices and credit notes produced for a procurement (or for all of a job's procurements). Invoices appear only **after** `order_confirmed`. Like step 5, this is **not part of the public `2026-01` contract** — its response carries `"x_extension": true`. Use the **repairer** credentials again, with exactly one selector (`procurement_identity` XOR `job_identity`): ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.invoices.list" \ -H "x-partifact-tenant: $TENANT" \ -H "Authorization: Bearer $API_KEY" \ -H "Partly-Integration-ID: $INTEGRATION_ID" \ -H "Content-Type: application/json" \ -d "{\"procurement_identity\":{\"id\":\"$PROCUREMENT_ID\"}}" ``` Lists invoices for the procurement you just confirmed. Expected response shape (abridged): ```json { "x_extension": true, "items": [ { "document_type": "invoice", "status": "reconciled", "currency_code": "NZD", "total": "1099.00", "line_items": [ { "reconciliation": "matched" }, { "reconciliation": "matched" } ] } ] } ``` That closes the buyer loop: a reconciled invoice for `1099.00` NZD, every line `matched`. Providing both selectors or neither returns `{"type":"bad_request"}`. --- ## What next You now have working credentials and a full place → confirm → reconcile transcript. Go deeper: - **[Lifecycle](./lifecycle.md)** — the full ordered flow, every status transition, and exactly where the faithful `2026-01` contract ends and the proposed extensions begin. - **[Webhooks](./webhooks.md)** — the HMAC-SHA256 signed event envelope, the 5-minute replay window, and copy-paste verification snippets (TypeScript + Python). - **[API Reference](./api-reference.md)** — all 14 wire methods plus the proposed extensions, with request/response schemas and coded errors. - **[For AI Agents](./for-ai-agents.md)** — the same lifecycle as an MCP server: 13 tools with enriched, self-correcting errors, for agents that build integrations from the docs alone. Machine-readable indexes: [`/llms.txt`](/llms.txt) (page map), [`/llms-full.txt`](/llms-full.txt) (full corpus), and [`/openapi.json`](/openapi.json) (the `2026-01` spec). --- # Authentication > Rails Sandbox is a Rails-compatible API — a faithful mock of the public `2026-01` Integrations API, plus clearly-labelled proposed extensions. It is not vendor documentation. Every authenticated call to the Rails Sandbox carries two headers: an `Authorization: Bearer ` and a `Partly-Integration-ID: `. You obtain that pair once, by exchanging a pre-registered OAuth client through the install endpoint. This page covers the install model, the two headers, the org/scope model, and every coded auth failure with its fix. If you just want a working call in under a minute, this sandbox pre-loads ready-to-use credentials so you can skip the install entirely — see [Quickstart](./quickstart.md). The install flow below is documented for fidelity to the contract; you do not have to run it. ## Base URL and RPC convention All examples target the deployed mock: ``` https://partifact-mock-rails.thanhvuttv.workers.dev ``` Every method is an RPC over `POST`: ``` POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/ ``` The path prefix is exactly `/api/2026-01/`, and the dotted method name is the final path segment (for example `.../api/2026-01/integrations.insert`). The request body is JSON; an empty body is treated as `{}`. See the [API Reference](./api-reference.md) for the full method inventory and [/openapi.json](/openapi.json) for the machine-readable spec. ## The install model `integrations.insert` is the **only** unauthenticated method in the entire API. It bootstraps the credential everything else needs, so it cannot itself require one. You call it with a pre-registered OAuth client and an access code; it returns a long-lived, org-scoped credential pair. **Request body** — three fields, all strings: | field | meaning | |---|---| | `client_id` | The pre-registered OAuth client identifier. | | `client_secret` | The client's secret, compared in constant time. | | `access_code` | A single-use authorization code (~15-minute TTL). | **Response** — the credential you store and reuse: ```json { "api_key": "partly_<32-hex-uuid>", "integration_id": "" } ``` The `api_key` is freshly minted (prefix `partly_` followed by a UUID with dashes stripped) and the `integration_id` is a fresh UUID. Both are long-lived: install once, then reuse the pair on every subsequent call. There is no token refresh in this contract. > **The access code is single-use.** A successful install consumes the code. Re-running install with the same `access_code` then fails `invalid_access_code` (401). To run the flow twice you need a fresh code. ### Pre-registered demo client Real vendor onboarding is a **manual** process — there is no self-serve credential issuance, and you cannot mint your own OAuth client. We label this honestly: in production you would be provisioned a `client_id` / `client_secret` out of band. In this sandbox, one demo client is pre-registered so the install flow is runnable end to end: | field | value | |---|---| | `client_id` | `partly_client_demo` | | `client_secret` | `secret_demo_8f3a` | | `access_code` | `ac_demo_valid_15m` (single-use) | This client is provisioned for the **repairer** organization (Canterbury Collision Group). ### Isolated sandboxes — `x-partifact-tenant` Because the demo `access_code` is **single-use**, the install flow only works once per world. To get your own fresh world — with an unconsumed access code — send an `x-partifact-tenant: ` header on every call. Each distinct value is a private copy of the sandbox, seeded identically and self-healing after an hour idle; changing the name is a zero-cost reset. This header is a **sandbox convenience for isolation**, not part of the contract (a real the tenant boundary is the organization). The [Quickstart](./quickstart.md) uses it throughout. ### curl — a successful install ```bash curl -sS -X POST \ https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/integrations.insert \ -H 'x-partifact-tenant: my-sandbox' \ -H 'Content-Type: application/json' \ -d '{ "client_id": "partly_client_demo", "client_secret": "secret_demo_8f3a", "access_code": "ac_demo_valid_15m" }' ``` Response (HTTP `200`; your values will differ — the credential is freshly minted): ```json { "api_key": "partly_3f2a9c7d1e0b4a86b5c2d4e6f8091a2b", "integration_id": "7b1e2c3d-4a5f-4061-8b9c-0d1e2f3a4b5c" } ``` > **If you run this against the live deploy and the demo `access_code` has already been consumed**, you will get `invalid_access_code` (401). That is expected — the code is single-use. Use the [pre-loaded credentials](./quickstart.md) instead, which never require the install dance. ## The two required headers (on every other call) Every method except `integrations.insert` requires both headers. Names are case-insensitive on the wire, but the canonical casing per the contract is: ``` Authorization: Bearer Partly-Integration-ID: ``` Notes: - The `Bearer ` scheme prefix is **required** on the `Authorization` header (the token is parsed by `/^Bearer\s+(.+)$/i`). A bare key with no `Bearer ` prefix does not authenticate. - Both headers must be present and must resolve as a known `(api_key, integration_id)` pair. If either is missing, unparseable, or the pair is unknown, the call fails `unauthorized` (401). A minimal authenticated call (reads the seeded job): ```bash curl -sS -X POST \ https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/repairer.jobs.get \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2' \ -H 'Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001' \ -d '{ "identity": { "external": "CCC-2026-04817" } }' ``` ## Org and scope model A credential is bound to one organization and carries one **scope** — a role that gates which methods it may call: - **`repairer`** drives the job: open the repair job, identify parts, fetch the basket, place a procurement. - **`supplier`** confirms: only a supplier-scope credential may call `supplier.procurements.confirm`. Parts procurement is an **inherently two-party protocol** — a repairer requests, a supplier confirms. In a real deployment these are two separate integrations on two separate organizations. To make the full lifecycle runnable by a single caller, **this sandbox pre-loads BOTH credentials**, so you can drive both sides by swapping the two header pairs: | scope | org | `api_key` | `integration_id` | |---|---|---|---| | `repairer` | Canterbury Collision Group | `partly_demo_repairer_3f8a1c0d9e2b4a67b1c2` | `0c000000-0000-4000-8000-000000000001` | | `supplier` | Christchurch Toyota — Parts | `partly_demo_supplier_8b4e2f1a6c0d3e9f7a25` | `0c000000-0000-4000-8000-000000000002` | > **Simplification, labelled.** Bundling both sides into one sandbox is a demo convenience, not the production model. Real API integrations hold exactly one role. These two seed keys carry a `partly_demo_...` prefix and are distinct from the `partly_` keys that `integrations.insert` mints. The supplier credential here is for S1, **Christchurch Toyota — Parts**. This sandbox holds only **one** supplier credential, so only orders placed with S1 are confirmable — see [Errors](./errors.md) and [Webhooks](./webhooks.md) for how that constraint surfaces. ## Coded auth and install failures Each variant is a type-tagged JSON body and a matching HTTP status. (The HTTP status is mock realism; the body's `type` is the contract signal.) The full error model — including the one method that returns bare-string variants — lives in [Errors](./errors.md). ### Cross-cutting (any authenticated method) | code | HTTP | body | meaning | fix | |---|---|---|---|---| | `unauthorized` | `401` | `{ "type": "unauthorized" }` | A required header is missing or unparseable, or the `(api_key, integration_id)` pair is unknown. A configuration issue, not a workflow step. | Send both headers with a valid pair; include the `Bearer ` prefix. Do not blindly retry — fix the credential first. | | `forbidden` | `403` | `{ "type": "forbidden" }` | The credential is valid but its scope is wrong for this method (right key, wrong role). | Use the credential whose scope matches the method — `supplier` for `supplier.procurements.confirm`, `repairer` for repairer methods. | ### Install-specific (`integrations.insert`) | code | HTTP | body | meaning | fix | |---|---|---|---|---| | `integration_not_found` | `404` | `{ "type": "integration_not_found" }` | `client_id` matches no registered OAuth client. | Check the `client_id`. In the sandbox, use `partly_client_demo`. | | `o_auth_not_supported_by_integration` | `403` | `{ "type": "o_auth_not_supported_by_integration" }` | The client exists but is not enabled for the OAuth install flow. | Use a client provisioned for OAuth install (the demo client `partly_client_demo` is). | | `invalid_client_secret` | `401` | `{ "type": "invalid_client_secret" }` | `client_secret` does not match `client_id` (constant-time compare). | Re-check the secret. In the sandbox, `secret_demo_8f3a` pairs with `partly_client_demo`. | | `invalid_access_code` | `401` | `{ "type": "invalid_access_code" }` | The `access_code` is unknown, expired (~15-min TTL), or already consumed (it is single-use). | Obtain a fresh access code. In the sandbox, if `ac_demo_valid_15m` is spent, use the [pre-loaded credentials](./quickstart.md) and skip install. | > There is no `invalid_client` code. The "client exists but cannot do OAuth install" case is named `o_auth_not_supported_by_integration` (403), exercised in the sandbox by the `partly_client_nooauth` client. ### One failing example A wrong secret against a valid client: ```bash curl -sS -i -X POST \ https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/integrations.insert \ -H 'Content-Type: application/json' \ -d '{ "client_id": "partly_client_demo", "client_secret": "wrong_secret", "access_code": "ac_demo_valid_15m" }' ``` Response: ``` HTTP/1.1 401 Unauthorized ``` ```json { "type": "invalid_client_secret" } ``` The fix is in the body: the secret does not match the client. Send `secret_demo_8f3a` for `partly_client_demo`. No coded auth or install error is ever resolved by a blind retry — each one names a specific input to correct. ## Next - [Quickstart](./quickstart.md) — run an authenticated call using the pre-loaded credentials, no install required. - [Errors](./errors.md) — the full error model, the bare-string method, and how the MCP layer enriches each code with a next step. - Machine-readable: [/openapi.json](/openapi.json), [/llms.txt](/llms.txt), [/llms-full.txt](/llms-full.txt). --- # Procurement lifecycle This page walks the parts-procurement lifecycle of **Rails Sandbox — a Rails-compatible API** (a faithful mock of the public Integrations API, plus clearly-labelled proposed extensions). It names every **dotted wire method** in order, the **status** each produces, and the exact point where the public `2026-01` contract ends. Every example below is an RPC-over-POST call: ``` POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/ Authorization: Bearer Partly-Integration-ID: Content-Type: application/json ``` See [Quickstart](./quickstart.md) for credentials and a first call, the [API Reference](./api-reference.md) for full request/response shapes, and [Errors](./errors.md) for the coded failure model. --- ## The two state machines Two things move through states: **parts** and **procurements**. The enum values below are quoted verbatim from the contract — do not invent others. **Part status** — `PartStatusV1`: ``` "estimated" | "ordered" ``` A newly inserted part starts `"estimated"`. It transitions to `"ordered"` once a procurement has been placed for it. **Procurement status** — `procurements.v1.buyer.Status`: ``` "order_requested" | "order_confirmed" | "cancelled" ``` A procurement is created in `"order_requested"` and is transitioned to `"order_confirmed"` by the supplier. `"order_confirmed"` is the **terminal public state** — see the boundary marker below. (`"cancelled"` is enumerated in the contract; the sandbox lifecycle does not drive a cancellation path.) ASCII view of the happy path: ```text ── PUBLIC 2026-01 CONTRACT ──────────────────────────┐ part: estimated ──(place)──► ordered │ │ procurement: (create) ──► order_requested ──(supplier confirms)──► │ order_confirmed │ ◄── terminal public state ────────────────────────────────────────────────────────────────── ┘ │ ── PROPOSED EXTENSIONS (beyond 2026-01) ┴── (see below) invoices appear after order_confirmed ``` --- ## Ordered walkthrough — `tier1.prepare` → `order_confirmed` The faithful path uses these dotted wire methods, in workflow order. Read-only resolution helpers (`repairer.work-providers.list`, plus the job-read methods) are listed where they fit. ### 1. `tier1.prepare` — warm the catalogue (no state change) Queues a vehicle VIN/VRM for future use in other the API (bulk of up to 1000). Fire-and-forget: returns `{ invalid_identifiers }` and makes **no state change** to any job, part, or procurement. ```bash curl -X POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/tier1.prepare \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{ "identifiers": [{ "vin": "JTDBR32E730012345" }] }' ``` ### 2. `repairer.work-providers.list` — resolve the insurer (read-only) Fetches the list of work providers (insurers) for a repairer so you can attach the right `work_provider_id` to a job. Empty request body. Omit the work provider entirely for cash / self-pay jobs. ### 3. `repairer.jobs.insert` — open or update a repair job Upserts a repair job (insert-or-update keyed by identity). On success it emits a `repairer.jobs` webhook whose payload carries `change_action` of `"inserted"` or `"updated"` (see [Webhooks](./webhooks.md)). No procurement exists yet. ```bash curl -X POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/repairer.jobs.insert \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{ "identity": { "external": "CCC-2026-04817" }, "repairer_site_id": "0b000000-0000-4000-8000-000000000001", "work_provider_id": "0a000000-0000-4000-8000-000000000020" }' ``` ### 4. `repairer.jobs.parts.insert` — identify parts A **full-list upsert** of the job's parts, keyed by part identity. Each newly inserted part starts at `status: "estimated"`. > Wire note: `repairer.jobs.parts.insert` is the one method whose error bodies are **bare JSON strings** (`"not_found"`, `"unprocessable"`, …), not `{ "type": "..." }` objects. This asymmetry is reproduced faithfully from the contract; see [Errors](./errors.md). ### 5. `repairer.jobs.baskets.latest.get` — recommend a basket (read-only) Fetches the latest priced basket for the job. Returns: ```json { "offers": [], "suppliers": [] } ``` A job that exists but has no priced parts returns an **empty basket**, not an error. > **Currency:** the basket response carries **no `currency` field**. `OfferV1` exposes `trade_price` (a bare string, NZD ex-GST) with no accompanying currency, and the basket envelope has only `offers` and `suppliers`. Currency is a procurement-level concept — it first appears on `procurements.v1.buyer.Procurement.currency_code` (`"NZD"`). This is decision **D52**: any currency shown alongside a basket offer is invented. The Rails MCP's projected basket may attach a *derived presentation-layer* `currency` label, but that is never a wire claim. ### 6. `repairer.procurements.get` — track a procurement (read-only) Buyer/repairer view of one procurement by UUID: its status and lines. A procurement belonging to a different org resolves as `not_found` (scoped read). ### 7. `supplier.procurements.confirm` — confirm the order Transitions a procurement `order_requested → order_confirmed`. This is a **supplier-scope** action: it requires a credential whose `scope` is `"supplier"`. On confirm the order is **locked**, an invoice is created, and buyer + supplier `…procurements` webhooks fire with status `order_confirmed`. The seed ships one procurement already placed in `order_requested` — **PR-1**, id `10000000-0000-4000-8000-000000000001` — so you can exercise confirm immediately, using the **supplier** credential: ```bash curl -X POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/supplier.procurements.confirm \ -H "Authorization: Bearer partly_demo_supplier_8b4e2f1a6c0d3e9f7a25" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000002" \ -H "Content-Type: application/json" \ -d '{ "identity": { "id": "10000000-0000-4000-8000-000000000001" } }' ``` > The supplier and repairer credentials are both preloaded by the seed, so a single caller can drive both sides by swapping the two header pairs. See [Quickstart](./quickstart.md). ### Lifecycle summary table | # | Dotted wire method | Faithful? | Resulting status / effect | |---|---|---|---| | 1 | `tier1.prepare` | faithful | Catalogue warmup; returns `{ invalid_identifiers }`; **no state change** | | 2 | `repairer.work-providers.list` | faithful | Read-only; lists insurers for the repairer | | 3 | `repairer.jobs.insert` | faithful | Upserts job; emits `repairer.jobs` webhook (`change_action`) | | 4 | `repairer.jobs.parts.insert` | faithful | Full-list upsert of parts; new parts start `estimated` | | 5 | `repairer.jobs.baskets.latest.get` | faithful | Read-only; `{ offers, suppliers }`; empty (not an error) if no basket. **No `currency` field** | | 6 | `repairer.procurements.get` | faithful | Read-only buyer view by UUID; cross-org → `not_found` | | 7 | `supplier.procurements.confirm` | faithful | `order_requested → order_confirmed`; locks order; creates invoice; fires webhooks | --- > **── End of the public 2026-01 contract: `order_confirmed` is the terminal state. ──** Everything above is a faithful mirror of the public `2026-01` surface. The public lifecycle **terminates at `order_confirmed`**. The methods below are **not** part of that contract. --- ## Proposed extensions (beyond 2026-01) These two steps complete the buyer's procurement loop — placing the order and reconciling the resulting invoice — but the public `2026-01` API exposes **no public method** for either. the upstream platform performs procurement-placement and invoice-reconciliation internally; here they are reconstructed as a **clearly-labelled proposed extension** so the end-to-end loop is demonstrable, never passed off as the real contract. Every extension method announces itself: its HTTP response carries `x_extension: true`, and the Rails MCP result carries `is_extension: true`. Both extension surfaces open with this verbatim banner: > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) ### `repairer.procurements.insert` — place a procurement (proposed extension) > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) Creates a procurement in `status: "order_requested"` from a selection of offers, and moves the selected parts `estimated → ordered`. It emits buyer + supplier `…procurements` webhooks (status `order_requested`). This is the step that, in the faithful path, the seed has already performed for PR-1; for a brand-new job you place first, then confirm. The full proposed-extension workflow order is therefore: ```text prepare_vehicle → open_repair_job → identify_parts → recommend_basket → place_procurement (ext) → confirm_procurement → reconcile_invoice (ext) ``` #### Single-confirmable-supplier constraint The seed has **three supplier orgs** but **one supplier credential** — **S1, "Christchurch Toyota — Parts"**, business_id `0b000000-0000-4000-8000-000000000010`. A supplier can only confirm its **own** orders. Because this demo holds only S1's supplier credential, **only a procurement placed from S1's offers can ever reach `order_confirmed`**. Where that constraint bites depends on which surface you use: - **On the raw wire**, `repairer.procurements.insert` is **permissive** — it will happily create a procurement from S2 / S3 offers and return `order_requested`. The mismatch only surfaces later, at **confirm** time: `supplier.procurements.confirm` returns `not_found` because no seeded credential can act as that supplier, and the order is left stranded in `order_requested`. - **Through the Rails MCP**, the `place_procurement` tool adds a client-side guard (decision **D53**): if any selected offer's `supplier_business_id` is not S1's, it **fails fast before the wire** with an `unprocessable` extension error that names the only confirmable supplier and points you back to the basket — so an agent never creates the dead-end order in the first place. This guard lives in the MCP tool, not in the wire contract. See [Errors](./errors.md) for the exact recovery text. ### `repairer.procurements.invoices.list` — reconcile invoices (proposed extension) > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) Reads reconciled invoices and credit notes for a procurement (or job). An invoice is created on confirm with `status: "reconciled"`, `document_type: "invoice"`, and each line `reconciliation: "matched"`. Invoices therefore appear **only after `order_confirmed`** — calling this before the order is confirmed returns nothing to reconcile. --- ## Re-reading state — never assume, re-fetch The lifecycle is event-driven and webhooks are identifier-only (payloads carry ids, not records), so after any transition you re-fetch the current truth by id: - **`repairer.jobs.get`** — the job **header** (vehicle, `repairer_site_id`, `work_provider_id`, `claim_number`, images) by `external` reference (e.g. `CCC-2026-04817`) or `id`. The wire `jobs.get` does **not** embed parts, procurements, or invoices — read those separately via `repairer.jobs.parts.list`, `repairer.procurements.get`, and `repairer.procurements.invoices.list`. (The Rails MCP's `get_job` aggregates all of them into one composite view; the wire method does not.) The natural first call to orient yourself on the seeded job. - **`repairer.procurements.get`** — buyer-view read of one procurement's status and lines by UUID. Call it after a place or confirm to read the new status rather than inferring it. After you confirm, call `repairer.procurements.get` for the new truth (`order_confirmed`), and `repairer.jobs.parts.list` to see the ordered parts flipped to `ordered`. --- ## See also - [Quickstart](./quickstart.md) — credentials, headers, and your first call. - [API Reference](./api-reference.md) — full request/response schemas for every method. - [Errors](./errors.md) — the coded error model, the bare-string asymmetry, and retryability. - [Webhooks](./webhooks.md) — the events emitted at each transition. - Machine-readable: [/openapi.json](/openapi.json), and the LLM digests [/llms.txt](/llms.txt) and [/llms-full.txt](/llms-full.txt). --- # Webhooks Rails Sandbox emits **signed, at-least-once** event notifications so your integration can react to state changes — a job inserted, a procurement moving to `order_confirmed` — without polling. This page is the byte-for-byte contract: the envelope, the signature scheme, the replay window, and complete copy-paste verification functions in TypeScript and Python that match the signer exactly. > Rails Sandbox is a Rails-compatible API — a faithful mock of the public Integrations API. The webhook signing, envelope, and delivery semantics described here mirror the contract shape. Where this build simplifies, it is labelled. This is the verifying-consumer side of the loop. For the request/response API you call, see the [API Reference](./api-reference.md); for how delivery errors surface, see [Errors](./errors.md). --- ## 1. Subscription model You register a consumer URL that Rails Sandbox will `POST` each signed notification to. Registration is a single dev-only call: ``` POST https://partifact-mock-rails.thanhvuttv.workers.dev/__webhooks/subscribe Content-Type: application/json { "integration_id": "", "url": "http://localhost:/" } ``` - **`integration_id`** — the integration whose events you want delivered (the seeded repairer `0c000000-0000-4000-8000-000000000001`, the seeded supplier `0c000000-0000-4000-8000-000000000002`, or the `integration_id` you minted via `integrations.insert`). - **`url`** — where each delivery is `POST`ed. It **must be a loopback address** (`http(s)://localhost`, `127.0.0.1`, `[::1]`, or `0.0.0.0`); any other host is rejected. Responses: success `200 { "ok": true }`; non-loopback url `400 { "ok": false, "reason": "url_not_loopback" }`; a missing field `400 … "missing_field"`; an unparseable body `400 … "bad_body"`. Once registered, every signed webhook for that `integration_id` is `POST`ed to your url **verbatim** — the exact raw body bytes plus the `partly-hmac-sha256` header — right after the API call that produced it returns `200` (best-effort; a slow or failing consumer never fails the originating call). A single confirm fires **two** events (buyer + supplier); each is delivered to whichever integration registered for it. The `__` prefix marks this route — and the reference sink `/__webhooks/sink` — as **out-of-contract dev surfaces**: sandbox plumbing for running a consumer next to the mock, not part of the `2026-01` API. Registrations are **in-memory and per-process** (not persisted) — re-subscribe after a restart. > **Local-dev only — by design.** The loopback restriction is both an SSRF guard and the line that keeps the deployed sandbox faithful. On the **deployed** Worker the edge cannot reach your laptop's `localhost`, so outbound delivery there is a **no-op**: the deployed sandbox keeps a faithful outbox-only posture and never calls out. To actually *receive* live deliveries, run the mock locally (`pnpm --filter @partifact/mock-rails dev`) and register a `localhost` url against that instance. Against the **deployed** sandbox, poll `repairer.procurements.get` for status instead of waiting on a push. This is a clearly-labelled simplification of a real outbound delivery — see the project README's fidelity notes. ### The reference consumer The sink at `POST /__webhooks/sink` is a working verifying consumer you can read as a template: it verifies the signature, dedups on `message_id`, and acks `200` immediately. The verification functions in §6–§7 are exactly what it runs — use them as the basis for your own endpoint. --- ## 2. Delivery semantics - **At-least-once.** A given event may arrive more than once (retries, edge replays). You must make processing **idempotent**. - **Dedup on `message_id`.** Each notification carries a unique `message_id` (a UUID). Keep a seen-set; if you have already processed a `message_id`, ack `200` and do nothing else. The reference sink does exactly this and reports `{ ok: true, deduped: }`. > **Simplification (dev sink):** the reference sink's dedup set is in-memory and ephemeral — it is bounded to a single eval run, not a durable store. Your production consumer should persist the seen-set. - **Ack fast.** Return `200` as soon as you have verified and enqueued the event. Do the real work asynchronously. - **5-minute replay window.** Reject any notification whose `webhook_timestamp` is more than **5 minutes** away from now — in the past *or* the future (the check is `|now − webhook_timestamp| > 5 min`). This bounds replay attacks. --- ## 3. The envelope — exactly 8 fields Every notification body is a JSON object with these **8 fields**, names and types verbatim: | # | Field | Type | Notes | |---|-------|------|-------| | 1 | `message_id` | `string` | UUID. The **dedup key**. | | 2 | `event_timestamp` | `string` | ISO-8601. When the underlying event occurred (defaults to the sent time). | | 3 | `webhook_timestamp` | `string` | ISO-8601. The **sent** time — drives the 5-minute replay window. | | 4 | `event_type` | `string` (3-value union) | One of the 3 event types below. | | 5 | `type` | `"integration_notification"` (const) | Always this literal. | | 6 | `version` | `"v1"` (const) | Always this literal. | | 7 | `integration_id` | `string` | The recipient integration. Used to look up the verifying secret. | | 8 | `payload` | object | **Identifier-only** (see below). | ### Payloads are identifier-only — re-fetch by id Notifications **do not embed records**. The `payload` carries identifiers; you re-fetch the current state through the API (e.g. `repairer.jobs.get`, `repairer.procurements.get`). This avoids acting on a stale snapshot delivered out of order. ```jsonc // payload for a repairer.jobs event { "job_id": "string", "change_action": "inserted" | "updated" | "deleted" } // payload for a repairer.procurements / supplier.procurements event { "procurement_id": "string", "job_id": "string", "status": "order_requested" | "order_confirmed" } ``` A complete notification on the wire looks like: ```json { "message_id": "a1b2c3d4-0000-4000-8000-000000000abc", "event_timestamp": "2026-06-05T03:14:00.000Z", "webhook_timestamp": "2026-06-05T03:14:00.000Z", "event_type": "supplier.procurements", "type": "integration_notification", "version": "v1", "integration_id": "0c000000-0000-4000-8000-000000000002", "payload": { "procurement_id": "10000000-0000-4000-8000-000000000001", "job_id": "0d000000-0000-4000-8000-000000000001", "status": "order_confirmed" } } ``` > **Lifecycle note.** `order_confirmed` is the **terminal public state** of the `2026-01` contract. Events beyond that point (procurement placement, invoice reconciliation) belong to the proposed extensions, which fire under the same `repairer.procurements` / `supplier.procurements` event types but are clearly labelled in the [API Reference](./api-reference.md). The public webhook `status` enum in the payload is `order_requested | order_confirmed`. --- ## 4. The 3 event types `event_type` is one of exactly these: | `event_type` | Fires when | |--------------|-----------| | `repairer.jobs` | A repair job is inserted or updated (`change_action`). | | `repairer.procurements` | A procurement the **repairer/buyer** is party to changes status. | | `supplier.procurements` | A procurement the **supplier** is party to changes status (e.g. on confirm). | A single state change (e.g. a confirm) fires both the buyer-facing and supplier-facing procurement events, each delivered to the integration that should receive it. --- ## 5. Signature scheme Every delivery carries an HMAC signature in a header: - **Header name (lowercase):** `partly-hmac-sha256` - **Value:** `base64( HMAC-SHA256( secret, RAW_REQUEST_BODY ) )` That is: the **secret is the HMAC key**, the message is the **raw request-body bytes**, and the digest is base64-encoded. ### The secret The signing key is the integration's **`pwh_`-prefixed shared webhook secret**, looked up per `integration_id`. The seeded demo secrets are: | Scope | Webhook secret | |-------|----------------| | Repairer | `pwh_demo_repairer_a1b2c3d4e5f6` | | Supplier | `pwh_demo_supplier_9a8b7c6d5e4f` | Your consumer reads `integration_id` from the body and looks up the corresponding `pwh_...` secret to verify. ### Sign and verify over the RAW bytes — never re-serialized JSON > **Security-critical.** Compute the HMAC over the **exact bytes** of the request body you received (e.g. `await c.req.text()` in a Worker, `request.get_data()` in Flask). **Do not** `JSON.parse` / `json.loads` and then re-stringify — re-serialization reorders and re-spaces keys, which changes the bytes and **breaks the signature**. Capture the raw body first, verify, then parse. ### Use a constant-time compare > **Security-critical.** Compare the recomputed digest against the received signature with a **constant-time** comparison (XOR-accumulate, or `hmac.compare_digest`). A naive `===` / `==` byte compare leaks timing information that can be used to forge a signature. Both functions below length-check first (to avoid a `RangeError`) and then compare in constant time. ### Reject reasons The verifier distinguishes: `missing_signature`, `bad_signature`, `missing_timestamp`, `stale_timestamp`. It checks the signature first, then the timestamp's presence, validity, and freshness. --- ## 6. Verification — TypeScript (Web Crypto `crypto.subtle`) Portable to Cloudflare Workers and Node 20+ (no `node:crypto`). Pass the **raw** body string. ```ts // Verify a Rails Sandbox webhook. Pass the RAW request body string (do NOT re-stringify // a parsed object — that breaks the HMAC). Header: `partly-hmac-sha256`. async function verifyWebhook( rawBody: string, signatureHeader: string | null | undefined, // value of `partly-hmac-sha256` secret: string, // the integration's pwh_... secret nowMs: number = Date.now(), ): Promise<{ ok: boolean; reason?: string }> { if (!signatureHeader) return { ok: false, reason: "missing_signature" }; // 1) Recompute HMAC-SHA256 over the RAW body bytes, keyed by the secret. const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody)); const expected = new Uint8Array(sigBuf); // 2) base64-decode the header, length-check, then constant-time compare. let received: Uint8Array; try { const bin = atob(signatureHeader); received = Uint8Array.from(bin, (c) => c.charCodeAt(0)); } catch { return { ok: false, reason: "bad_signature" }; // malformed base64 } if (received.length !== expected.length) return { ok: false, reason: "bad_signature" }; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ received[i]; if (diff !== 0) return { ok: false, reason: "bad_signature" }; // 3) Replay window: webhook_timestamp must be within 5 minutes of now. let ts: unknown; try { ts = (JSON.parse(rawBody) as { webhook_timestamp?: unknown }).webhook_timestamp; } catch { return { ok: false, reason: "missing_timestamp" }; } if (typeof ts !== "string") return { ok: false, reason: "missing_timestamp" }; const tsMs = Date.parse(ts); if (Number.isNaN(tsMs)) return { ok: false, reason: "missing_timestamp" }; if (Math.abs(nowMs - tsMs) > 5 * 60 * 1000) return { ok: false, reason: "stale_timestamp" }; return { ok: true }; } ``` --- ## 7. Verification — Python (`hmac` / `hashlib` / `hmac.compare_digest`) Pass the **raw** body bytes (e.g. Flask `request.get_data()`), never a re-serialized dict. ```python import hmac, hashlib, base64, json, time def verify_webhook(raw_body: bytes, signature_header: str | None, secret: str, now_ms: int | None = None) -> tuple[bool, str | None]: """Verify a Rails Sandbox webhook. `raw_body` MUST be the exact request bytes (do NOT json.loads then re-dump — that breaks the HMAC). `signature_header` is the value of the `partly-hmac-sha256` header.""" if now_ms is None: now_ms = int(time.time() * 1000) if not signature_header: return False, "missing_signature" # 1) Recompute HMAC-SHA256 over the RAW body bytes, keyed by the secret. expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest() # 2) base64-decode the header; constant-time compare. try: received = base64.b64decode(signature_header, validate=True) except Exception: return False, "bad_signature" if not hmac.compare_digest(expected, received): return False, "bad_signature" # 3) Replay window: webhook_timestamp within 5 minutes of now. try: ts = json.loads(raw_body).get("webhook_timestamp") except Exception: return False, "missing_timestamp" if not isinstance(ts, str): return False, "missing_timestamp" # ISO-8601 → epoch ms (handles trailing 'Z'). from datetime import datetime, timezone try: ts_ms = int(datetime.fromisoformat(ts.replace("Z", "+00:00")) .replace(tzinfo=timezone.utc).timestamp() * 1000) \ if ("+" not in ts and "Z" in ts) else \ int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 1000) except Exception: return False, "missing_timestamp" if abs(now_ms - ts_ms) > 5 * 60 * 1000: return False, "stale_timestamp" return True, None ``` Both functions are byte-identical in behaviour to the server's signer: `importKey`/`hmac.new` with the **secret as key**, `sign`/`.digest()` over the **raw body**, base64-decode the header, length-check, then constant-time compare, and the 5-minute `webhook_timestamp` window. --- ## 8. Consumer checklist 1. Capture the **raw** body bytes before parsing. 2. Read the `partly-hmac-sha256` header. 3. Look up the `pwh_...` secret for the body's `integration_id`. 4. Verify (HMAC over raw bytes, constant-time compare, 5-min `webhook_timestamp` window). 5. Dedup on `message_id`; ack `200` (and `{ deduped: true }` for repeats). 6. **Re-fetch** the referenced record by id — payloads are identifier-only. --- ## Related - [Errors](./errors.md) — the coded error model and how failures surface. - [API Reference](./api-reference.md) — the request/response methods, including the procurement lifecycle that emits these events. - Machine-readable indexes: [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt), [`/openapi.json`](/openapi.json). --- # Errors Errors in Rails Sandbox are written to be read by LLMs as well as by humans. Each failure is a small, coded value with a stable `type` (or, in one historical method, a bare string) — never a stack trace, never prose-only. An agent can branch on the code; a human can read the same payload. This page is the reference for what comes back, what each code means, the next action to take, and the one rule that matters most: **no server contract error is ever retryable — take a different action, never a blind retry.** > Rails Sandbox is a Rails-compatible API: a faithful mock of the public Integrations API (`2026-01`) plus a small set of clearly-labelled proposed extensions. It is not vendor documentation. See also: the [Lifecycle](./lifecycle.md) (where each error can occur in the flow) and the [API Reference](./api-reference.md) (per-method error unions). Machine-readable surfaces: [/llms.txt](/llms.txt), [/llms-full.txt](/llms-full.txt), [/openapi.json](/openapi.json). ## The wire error model There is **no shared error envelope**. Rails Sandbox does not wrap failures in a common `{code, message, retryable}` object. Instead, each method declares its own error union, and **the variant object _is_ the response body**. A handler signals failure by throwing; the transport renders that variant plus an HTTP status. The standard form is a type-tagged object — a single discriminant field, `type`: ```json { "type": "not_found" } ``` The standard variants, with their HTTP status (the status is mock realism — the OpenAPI spec declares only `200` and `default`, so the body's `type` is the contract signal, and the status is there because a real HTTP client expects one): | Wire body | HTTP | Meaning | |---|---|---| | `{ "type": "unauthorized" }` | 401 | API key or `Partly-Integration-ID` missing/invalid. | | `{ "type": "forbidden" }` | 403 | Credentials valid but wrong scope (e.g. a supplier-only method called with a repairer credential). | | `{ "type": "not_found" }` | 404 | No record matches the id/identity, or it is out of your org's scope. | | `{ "type": "bad_request" }` | 400 | Identity/selector missing, ambiguous, or both-and-neither. | | `{ "type": "unprocessable", "message": "…" }` | 422 | The request is well-formed but rejected; the **only** standard variant carrying a `message`. | | `{ "type": "work_provider_not_found" }` | 404 | The `work_provider_id` (insurer) does not exist. | The install method (`integrations.insert`, the only unauthenticated method) has its own union — see [Authentication](./authentication.md): `integration_not_found` (404), `o_auth_not_supported_by_integration` (403), `invalid_client_secret` (401), `invalid_access_code` (401). ## The bare-string asymmetry (D24) There is exactly **one** exception to the type-tagged shape. `repairer.jobs.parts.insert` (the `identify_parts` MCP tool) returns its error variants as **bare JSON strings**, not `{type}` objects. Its union is a union of string consts: `"unauthorized" | "forbidden" | "not_found" | "unprocessable"`. The same logical error therefore has two different wire shapes depending on the method. Compare a `not_found`: ```json { "type": "not_found" } ``` ```json "not_found" ``` The first is what every standard method returns. The second — a bare JSON string, the literal four characters `"not_found"` as the entire response body — is what `repairer.jobs.parts.insert` returns. **Why reproduce this instead of normalizing it?** Because it is faithful to the real the contract: `JobsPartsInsertErrorV1` in the OpenAPI spec is a `oneOf` of bare string consts, while sibling error unions are `oneOf` of `{type}` objects. Smoothing it to `{type:"not_found"}` for this one method would be a fidelity regression — a documented mock should match the contract it claims to mock, warts included. A conformance test asserts the negative: `{type:"not_found"}` is **non-conformant** for this method. This is a labelled simplification only in the sense that the asymmetry is real and we did not paper over it. If you call this method directly over the wire, branch on the string value, not on a `.type` field. If you call it through the [Rails MCP](./for-ai-agents.md), the asymmetry is already hidden for you: the MCP layer maps both shapes to a single uniform `code`, so an agent never sees the difference. ## Common coded variants, by method group Each row is the code an agent will see and the next step that resolves it. (For methods reached through the MCP, this is also the `message` you get back — the message names the next action so the agent self-corrects from the message alone.) ### Auth and scope (every authenticated method) | Code | Meaning | Next step | |---|---|---| | `unauthorized` | API key or `Partly-Integration-ID` missing/invalid. A **configuration** issue, not a workflow step. | Fix the credentials; do not retry or re-auth in a loop. Through the MCP the server already holds demo creds — this should not occur. | | `forbidden` | Credentials valid but lack the scope. Supplier-only actions (`confirm_procurement`) need the supplier role; repairer actions need the repairer role. | Use the correct-scope tool / credential pair. Do not retry with the same scope. | | `bad_request` | Identity/selector missing, ambiguous, or both-and-neither. | Send exactly one well-formed identity (a job id **XOR** an external reference), then retry. | ### Jobs | Code | Meaning | Next step | |---|---|---| | `work_provider_not_found` | The `work_provider_id` (insurer) does not exist. | List valid providers via `list_work_providers`, or omit the field for a cash / self-pay job. | | `not_found` | No record matches the id/identity (or it is out of scope). | Re-check the id via `get_job` / `track_procurement`, then retry. | ### Parts (`repairer.jobs.parts.insert` — bare-string on the wire, enriched uniformly via the MCP) | Code | Meaning | Next step | |---|---|---| | `not_found` | The job you are adding parts to does not exist. | Call `open_repair_job` first (or fetch the right id via `get_job`), then re-submit the parts. | | `unprocessable` | A part was rejected — each part needs a description (or an explicit identity). | Provide a free-text description (and quantity) for every part, then retry; this tool resubmits the full list for you. | ### Basket | Code | Meaning | Next step | |---|---|---| | `not_found` | No basket: the job id/identity does not resolve to a job. | Open it with `open_repair_job`, add parts with `identify_parts`, then call `recommend_basket`. A job that exists but has no priced parts returns an **EMPTY basket, not this error**. | > The basket response carries **no `currency` field**. Currency is a procurement-level concept (D52) — it appears later in the lifecycle on the procurement (`currency_code`), never on a basket offer. Any code that surfaces a currency on a basket offer is inventing it. The MCP's _projected_ basket may show a derived `NZD` label, but that is a presentation-layer convenience, not a wire claim. ### Procurement (place / get / invoices) | Code | Meaning | Next step | |---|---|---| | `unprocessable` (place) | Your offer selection is not in the job's latest basket — unknown `sellable_id`/`supplier_business_id`, quantity over the offer, or mismatched `job_part_ids`. | Re-fetch `recommend_basket`, pick an offer it returned, then retry. | | `not_found` (get) | No procurement matches that id. | Get a valid id from `place_procurement`, or list a job's procurements via `get_job`, then retry. | | `bad_request` (invoices) | Selector missing or doubled. | Provide exactly one selector — a `procurement_id` **OR** a job selector (`job_id` / `external_id`), not both, not neither. | ### Confirm (`supplier.procurements.confirm`) | Code | Meaning | Next step | |---|---|---| | `not_found` | One of three causes: (1) the procurement id is unknown, (2) it is not in `order_requested` (already confirmed/cancelled), or (3) it belongs to a supplier this server cannot act as. | Re-check with `track_procurement`. If it still shows `order_requested`, the order is with a different supplier — re-place it via `place_procurement` selecting the seeded supplier's offers. | | `forbidden` | The procurement belongs to a different supplier org. | Do not retry with the same scope. | ## Retryability: never blind-retry a contract error (D41) **No server contract error is ever retryable.** A a contract error always needs a _different_ action — a fixed input, a different id, the correct-scope tool — never the same call sent again. `retryable: true` is reserved exclusively for **transport faults** — conditions where the call never completed or never reached a contract handler, so nothing about your inputs is wrong. There are exactly two: | Transport fault | `retryable` | Meaning | Action | |---|---|---|---| | `network_error` | `true` | The call did not complete; the request never reached the API. (`http_status: 0`.) | Nothing in your inputs is wrong — retry the same call as-is. | | `non_json_response` | `true` | The server returned a non-JSON body, likely a transient edge error — not a contract error. (Carries the real upstream status.) | Not an input problem — retry the same call as-is. | Every other code — every coded contract variant, plus the SDK-local `invalid_request_body` and the `unknown` / `internal` fallbacks — is `retryable: false`. The semantic the MCP attaches to the field is exact: `retryable: true` means "retry the **same** call verbatim (transport only)"; `retryable: false` means "fix or act first." An agent that treats a `403 forbidden` as retryable will loop forever; the contract is designed so it cannot. ## Worked example: place_procurement with a non-confirmable supplier (D53) This is the most instructive error in the system, because it is caught _before_ the wire and explains itself fully. `place_procurement` is itself a [clearly-labelled proposed extension](./lifecycle.md): > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) The demo seed ships **three supplier organizations but only one supplier credential** — S1, `"Christchurch Toyota — Parts"` (business_id `0b000000-0000-4000-8000-000000000010`). Confirming a procurement is a supplier-scope action, and a supplier can only confirm its **own** orders. So an order placed against S2 or S3 offers could be created, but it could never be confirmed — it would be a dead-end stuck in `order_requested`, unable to reach `order_confirmed`. Rather than let an agent dig that hole, `place_procurement` **fails fast**. If any selected offer's `supplier_business_id` is not S1's, the tool refuses the call up front and returns: - `code`: `unprocessable` - `is_extension`: `true` (it is the extension tool reporting) - `http_status`: `0` — the call never reached the API; this is a local guard The guidance message names the only confirmable supplier and points back to `recommend_basket`: ```text No order was placed. All selected offers must come from a SINGLE supplier this server can later confirm as, and this demo holds ONE supplier credential — "Christchurch Toyota — Parts" (its OEM "Toyota Genuine …" offers in recommend_basket). An order placed with any other supplier could never reach order_confirmed, so it is refused up front rather than left as a dead-end order_requested. Re-run recommend_basket and select offers whose supplier_business_id is Christchurch Toyota — Parts, then retry place_procurement. ``` A per-call `detail` is appended, naming the offending ids and the confirmable supplier: ```text You selected offers from supplier_business_id(s) . The only confirmable supplier in this demo is "Christchurch Toyota — Parts" (business_id 0b000000-0000-4000-8000-000000000010). ``` Two craft details worth noting. First, this guard uses a **synthetic method tag** — `repairer.procurements.insert.supplier_scope`, which is never a real wire method — so its message does not collide with the genuine `repairer.procurements.insert|unprocessable` ("offers not in basket") guidance above. Second, because `http_status` is `0` and the code is `unprocessable` (not a transport fault), this error is **not retryable**: the agent must change its offer selection, exactly as the message instructs. That is the whole point of the design — the error tells an LLM what went wrong and what to do next, and the recovery is a different action, not a retry. ## Where to go next - [Lifecycle](./lifecycle.md) — where each error appears in the procurement flow, and the faithful-vs-extension boundary. - [API Reference](./api-reference.md) — the per-method error union for every wire method. - [Authentication](./authentication.md) — the install (`integrations.insert`) error variants and the header contract. --- # Versioning & changelog Rails Sandbox is a faithful mock of the public Integrations API plus a small set of clearly-labelled proposed extensions. This page covers how the API is versioned and what has changed. The version is **`2026-01`**, a date-stamped string carried in the URL path. Every call goes to: ``` POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/ ``` The version segment is part of the route, not a header or query parameter. The path prefix is exactly `/api/2026-01/`, and the dotted method name is the final path segment — for example `.../api/2026-01/repairer.jobs.get`. See the [API Reference](./api-reference.md) for the full method inventory. ## Why date-versioning A date-stamped version string (`YYYY-MM`) names a frozen snapshot of the contract. Code written against `2026-01` keeps working as long as it keeps sending requests to `/api/2026-01/...`, because that path will always answer with the `2026-01` request and response shapes. You opt into a newer contract deliberately, by changing the version segment in your URLs — never implicitly, because a behaviour changed underneath you. This mirrors the API's own date-versioning convention; this sandbox reproduces the contract *shape*, not real production infrastructure. ## How a breaking change rolls a new version Within a single dated version the contract is additive-only. A change is **non-breaking** — and ships inside `2026-01` — when it cannot break a correct existing client. Examples: - Adding a new method under `/api/2026-01/`. - Adding an optional request field that defaults to today's behaviour when omitted. - Adding a new field to a response (well-behaved clients ignore unknown fields). A change is **breaking** — and would require a *new* dated version, e.g. a hypothetical `2026-07`, served under `/api/2026-07/...` — when it could break a correct existing client. Examples: - Removing or renaming a method, request field, or response field. - Tightening validation so a previously-accepted request is now rejected. - Changing the type, units, or meaning of an existing field. - Adding a new enum value a client must handle (e.g. a new procurement status). When that happens, `2026-01` continues to be served unchanged for existing integrations, and the new shapes live only under the new dated prefix. Clients migrate by changing the version segment in their URLs and adapting to the documented deltas — there is no silent cutover. > Simplification: this sandbox currently serves a single version, `2026-01`. There is no `2026-07` prefix deployed. The policy above describes how a breaking change *would* be rolled; it is documented here so the versioning contract is legible, not because a second version exists today. ## Proposed extensions are not part of 2026-01 Two methods — and their MCP tools `place_procurement` and `reconcile_invoice` — are **proposed extensions** beyond the public `2026-01` contract, not part of it. The public `2026-01` procurement lifecycle terminates at `order_confirmed`; these extensions complete the buyer loop past that terminal state (place a procurement, then reconcile its invoices). Because they are not part of the dated contract, they are labelled everywhere they appear: - The MCP tool descriptions open with the verbatim banner below. - Their HTTP responses carry `x_extension: true`; the MCP results carry `is_extension: true`. - They are documented in their own sections, never mixed in with the `2026-01` surface. > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) These extensions are versioned *with* the rest of the sandbox but are explicitly excluded from the `2026-01` contract guarantees. Treat them as a design proposal, not a shipped API. See the [API Reference](./api-reference.md) for their request/response shapes and the extension markers. ## Changelog Dates are the change dates; entries name the affected surface and the design decision (Dxx / R-Axx) they implement. ### 2026-06-04 — Stage 1 - **Presentation-layer currency on the basket projection (D52).** The MCP `recommend_basket` tool now derives and labels a currency (`NZD`) on its projected basket, as a presentation-layer convenience. This is *not* a change to the wire contract: the underlying `repairer.jobs.baskets.latest.get` response — and the `OfferV1` objects inside it — still carry **no** `currency` field. Currency is a procurement-level concept; it appears on the procurement (`currency_code`) and on invoices, never on the basket. The raw API docs continue to state the wire truth plainly. *(commit `7fc5137`)* - **`place_procurement` fails fast for a non-confirmable supplier (D53).** `place_procurement` (a proposed extension) now validates the chosen supplier *before* hitting the wire. The demo holds a single supplier credential — "Christchurch Toyota — Parts" — so a procurement placed from any other supplier's offers could never reach `order_confirmed`. Rather than leave such an order stranded in `order_requested`, the tool now refuses it up front with an `unprocessable` error that names the only confirmable supplier and points back to `recommend_basket`. *(commit `ea1b603`)* - **`get_run_metrics` per-session telemetry (R-A5).** Added a `get_run_metrics` MCP tool exposing per-session run counters, so an agent (or a human watching one) can read back what the current session has done. *(commit `bdaff71`)* Stage 1 close-out was recorded in commit `22b0300`. --- For the methods affected by these changes and their exact request/response shapes, see the [API Reference](./api-reference.md). Machine-readable surfaces: [`/openapi.json`](/openapi.json) (the `2026-01` contract), [`/llms.txt`](/llms.txt), and [`/llms-full.txt`](/llms-full.txt). --- # For AI agents You are an LLM or coding agent building an integration against **Rails Sandbox — a Rails-compatible API**. This is a faithful mock of the public `2026-01` Integrations API, plus a small set of clearly-labelled proposed extensions. It is not a real vendor's own documentation. This page tells you how to read these docs efficiently, where the machine-readable surfaces are, and how to tell apart the two things that look similar but are not: **this docs site** (for *building* an integration) and the **companion Rails MCP server** (for *driving* one at runtime). ## Two distinct surfaces — pick the right one | You want to… | Use | What it is | |---|---|---| | **Build** an integration — write HTTP/SDK code that calls the API | **This docs site** + `/openapi.json` | Prose contract, copy-paste examples, the typed OpenAPI spec | | **Drive** the procurement lifecycle at runtime as an agent | **The companion Rails MCP server** | Intent-level tools (`open_repair_job`, `recommend_basket`, …) that wrap the same wire methods | If your task is "implement a client / SDK / webhook receiver," stay on the docs site. If your task is "run a parts-procurement workflow end to end," connect the Rails MCP and call its tools — you do not need to hand-write HTTP requests at all. ## Machine-readable surfaces Every page on this site is available as raw markdown, and the whole corpus is downloadable in two forms. Prefer these over scraping rendered HTML. - **Raw markdown for any page** — append `.md` to the path, *or* send `Accept: text/markdown`. For example, [Quickstart](./quickstart.md) is fetchable as `/docs/quickstart.md`. - **`/llms.txt`** — the index: every page's title, summary, and link, in one short file. Read this first to plan which pages you need. - **`/llms-full.txt`** — the entire docs corpus concatenated into one file. Fetch this once if you want all the prose in a single context window instead of crawling page by page. - **`/openapi.json`** — the typed contract (OpenAPI 3.1). This is the source of truth for request/response/error schemas. Generate a typed client from it rather than transcribing field names by hand. ```bash # The four machine surfaces (replace the host if you self-host the mock). curl -s https://partifact-mock-rails.thanhvuttv.workers.dev/llms.txt curl -s https://partifact-mock-rails.thanhvuttv.workers.dev/llms-full.txt curl -s https://partifact-mock-rails.thanhvuttv.workers.dev/openapi.json # Any page as raw markdown — append .md: curl -s https://partifact-mock-rails.thanhvuttv.workers.dev/docs/quickstart.md # …or negotiate by content type: curl -s -H "Accept: text/markdown" https://partifact-mock-rails.thanhvuttv.workers.dev/docs/quickstart ``` ## Recommended reading order Read in this sequence. Each page assumes you've read the ones before it. 1. **[Quickstart](./quickstart.md)** — the base URL, the RPC-over-POST convention, and a first call you can make in under a minute against pre-loaded demo credentials. 2. **[Authentication](./authentication.md)** — the two required headers, the OAuth install flow (`integrations.insert`), and the coded auth/install failures. Note: you can skip the OAuth dance entirely — the mock pre-loads ready-to-use repairer and supplier credentials. 3. **[Lifecycle](./lifecycle.md)** — the ordered procurement lifecycle, wire method by wire method, and the public/extension boundary. 4. **[Webhooks](./webhooks.md)** — the HMAC-signed envelope, the 5-minute replay window, and verification snippets you can paste verbatim. 5. **[Errors](./errors.md)** — the per-method type-tagged error model and what each coded variant means. 6. **[API Reference](./api-reference.md)** — the full method-by-method contract; consult per call, don't read end-to-end. If you only have one fetch, get `/llms-full.txt` — it contains all of the above. ## The wire convention (so the examples make sense) - **Base URL:** `https://partifact-mock-rails.thanhvuttv.workers.dev` - **Every call:** `POST /api/2026-01/` with a JSON body. The dotted method name is the last path segment, e.g. `.../api/2026-01/repairer.jobs.get`. - **Auth headers on every call** except `integrations.insert`: - `Authorization: Bearer ` - `Partly-Integration-ID: ` ```bash # Read the seeded demo job. Pre-loaded repairer credentials — no install needed. curl -s https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/repairer.jobs.get \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"identity":{"external":"CCC-2026-04817"}}' ``` ## Errors name the next step When you build against this mock, error bodies are the **wire contract** — each method declares its own type-tagged union, and the variant object *is* the response body (there is no shared `{code, message}` envelope). The HTTP status is added for realism; the body's `type` is the signal. See [Errors](./errors.md) for the full table. If instead you DRIVE the lifecycle through the companion Rails MCP, the MCP layer goes one step further: it **enriches** every error into `{ code, message, retryable }` where `message` names the *next action* so you can self-correct from the message alone. Two rules worth internalizing: - `retryable: true` is reserved for **transport faults** (the call never reached the server). A contract error always needs a *different* action — never a blind retry. - After any state-changing call, **re-fetch** (`get_job` / `track_procurement`); do not assume the new state. ## The companion Rails MCP (runtime) A separate **Rails MCP server** exposes the same procurement lifecycle as intent-level tools and resources, for an agent to drive at runtime. It wraps the wire methods documented here, so the contract is identical — you just call named tools instead of constructing HTTP requests. It currently exposes **13 tools** and **8 resources**. **Tools (13):** `get_started`, `get_run_metrics`, `prepare_vehicle`, `find_repairer_site`, `list_work_providers`, `open_repair_job`, `get_job`, `identify_parts`, `recommend_basket`, `track_procurement`, `confirm_procurement`, `place_procurement` ⚠, `reconcile_invoice` ⚠. **Resources (8):** `guide://lifecycle`, `scenario://current`, `reference://suppliers`, `reference://ghca-categories`, `reference://work-providers`, `job://current`, `job://{job_id}` (template), `events://recent` (the webhook outbox). Notes: - Call **`get_started` first** — it returns the full tool order for the lifecycle. - `confirm_procurement` is the only **supplier**-scope tool; everything else is repairer-scope. - The ⚠ tools (`place_procurement`, `reconcile_invoice`) are **proposed extensions** (see below); they announce themselves at runtime via `is_extension: true`. The distinction is the whole point: **the docs site is for writing an integration; the MCP is for an agent to operate one.** A coding agent implementing a an API client reads these pages and `/openapi.json`. An agent running a repair-shop procurement workflow connects the MCP and calls its tools. ## Install & connect the MCP (deployed — no local build) Both MCP servers are **live on Cloudflare Workers**, so you can connect and test without cloning or building anything. Both speak **Streamable HTTP**. The **Rails MCP** is gated by a shared access token (it drives stateful, per-tenant sandboxes); the **Docs MCP** is **public and read-only** — no token. For the Rails MCP a connector UI (such as claude.ai) takes a URL, so the token rides in the path (or send it as an `Authorization: Bearer ` header). ### 1. The Rails MCP — *operate* the lifecycle The runtime server described above (13 tools + 8 resources). Connect it at: ``` https://partifact-rails-mcp.thanhvuttv.workers.dev/mcp// ``` - `` — the shared token provided with your invite. It only gates the endpoint against random traffic. - `` — **any name you choose** (`[a-z0-9_-]`). It selects your **own private sandbox**, seeded fresh with the same Corolla job (external reference `CCC-2026-04817`). Two people on two slots never touch each other's data; to start over, just change the name. Idle slots self-reset after ~1 hour. In **claude.ai → Settings → Connectors → Add custom connector**, paste the URL and save. Then, in a fresh chat with the connector enabled, ask the agent to drive the job — e.g. *"Using only the Rails MCP tools, take the 2019 Toyota Corolla job (`CCC-2026-04817`) through to a reconciled invoice, and tell me what you did at each step."* The agent orients itself from `get_started` / `guide://lifecycle` / `scenario://current` alone. ### 2. The Docs MCP — *read these docs* as tools A separate, read-only server that exposes **this documentation** as tools (`list_docs`, `get_doc`, `search_docs`) — for an agent that would rather query the docs than crawl the rendered site. It is **public** (no token) and stateless: ``` https://partifact-docs-mcp.thanhvuttv.workers.dev/mcp ``` No token, no slot — its corpus is already public (the same markdown this site serves), so there is nothing to gate. For example, `search_docs("verify webhook signature")` returns the [Webhooks](./webhooks.md) page. ### Liveness check (no token needed) ```bash curl https://partifact-rails-mcp.thanhvuttv.workers.dev/ # the runtime MCP — expect a 200 liveness line curl https://partifact-docs-mcp.thanhvuttv.workers.dev/ # the docs MCP — expect a 200 liveness line ``` > A local, zero-config install over **stdio** (Claude Desktop / Claude Code / Cursor) is also available for development — it runs the mock in-process, with no token and no slot. The deployed URLs above are the canonical way to test with no local build. ## Fidelity boundary — read before you trust a field This sandbox is faithful to the public `2026-01` contract, with two explicit additions. Hold the line on the following. **The public lifecycle terminates at `order_confirmed`.** `supplier.procurements.confirm` transitions a procurement `order_requested → order_confirmed`, which is the **terminal public state** of the `2026-01` contract. **Two proposed extensions go beyond it.** `place_procurement` (`repairer.procurements.insert`) and `reconcile_invoice` (`repairer.procurements.invoices.list`) are clearly-labelled proposed extensions, NOT part of the public `2026-01` API. Wherever they appear they carry this banner verbatim: > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) Their HTTP responses carry `x_extension: true`; the MCP equivalents carry `is_extension: true`. Treat these as a *proposal*, not the shipped contract. **The basket carries no currency.** The basket response (`repairer.jobs.baskets.latest.get`, returning `{ offers, suppliers }`) has **no `currency` field** — not on the envelope, not on an offer. Currency is a procurement-level concept (D52): it first appears later in the lifecycle as `currency_code` on the procurement. The MCP's *projected* basket derives an `NZD` label purely as a presentation-layer convenience; the wire basket itself asserts no currency. If you read a currency off a basket offer, you are inventing it — go to the procurement instead. ## Where to go next - [Quickstart](./quickstart.md) — make your first authenticated call. - [API Reference](./api-reference.md) — the full method-by-method contract. - `/openapi.json` — generate a typed client instead of transcribing schemas. --- # API Reference Generated from the published OpenAPI spec — the **14 wire methods** of the public `2026-01` Integrations API. This page is a per-method overview; [`/openapi.json`](/openapi.json) is the full machine-readable contract (every schema resolved), and the better source for generating a typed client. Every call is `POST /api/2026-01/` with a JSON body. See [Authentication](./authentication.md) for the two required headers and [Errors](./errors.md) for the coded error model. Examples use the pre-loaded demo credentials; set the base URL once, and prepend `-H 'x-partifact-tenant: '` for a private, isolated sandbox (see [Quickstart](./quickstart.md)). ```bash BASE=https://partifact-mock-rails.thanhvuttv.workers.dev ``` > **Wire-truth (D52).** The basket response (`repairer.jobs.baskets.latest.get` → `BasketsLatestGetResponseV1`, `OfferV1`) carries **no `currency` field**. Currency is a procurement-level concept — `currency_code` on the procurement and the invoice, never on a basket offer. > **Bare-string errors (D24).** Most methods return type-tagged error bodies (`{"type":"not_found"}`). The one exception is `repairer.jobs.parts.insert`, whose error variants are **bare JSON strings** (`"not_found"`). See [Errors](./errors.md). --- ## Wire methods (2026-01) ### `businesses.list` Lists the sub-businesses of the organization that the integration belongs to. | | | |---|---| | **Request** | `BusinessesListRequestV1` — required: _none_ | | **Response** | `BusinessesListResponseV1` — `items`: `BusinessV1`[] | | **Errors** | `unauthorized`, `forbidden` | ```bash curl -sS -X POST "$BASE/api/2026-01/businesses.list" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{}' ``` ### `integrations.insert` Completes the integration installation and API key generation for a given install. The request must provide the client secret, client ID and the access code generated during the OAuth authorization process | | | |---|---| | **Request** | `IntegrationsInstallRequestV1` — required: `access_code`, `client_id`, `client_secret` | | **Response** | `IntegrationsInstallResponseV1` — `api_key`: `std.string.String`, `integration_id`: `uuid.Uuid` | | **Errors** | `unauthorized`, `forbidden`, `integration_not_found`, `o_auth_not_supported_by_integration`, `invalid_client_secret`, `invalid_access_code` | ```bash curl -sS -X POST "$BASE/api/2026-01/integrations.insert" \ -H "Content-Type: application/json" \ -d '{"access_code":"ac_demo_valid_15m","client_id":"partly_client_demo","client_secret":"secret_demo_8f3a"}' ``` ### `repairer.jobs.baskets.latest.get` Fetches the latest basket for a given repair job. | | | |---|---| | **Request** | `BasketsLatestGetRequestV1` — required: `job_identity` | | **Response** | `BasketsLatestGetResponseV1` — `offers`: `OfferV1`[], `suppliers`: `SupplierV1`[] | | **Errors** | `unauthorized`, `forbidden`, `not_found`, `bad_request` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.baskets.latest.get" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"job_identity":{"external":"CCC-2026-04817"}}' ``` ### `repairer.jobs.get` Fetches a repair job by ID, external ID, or siteID and job number | | | |---|---| | **Request** | `_(inline)_` — required: `identity` | | **Response** | `JobV1` — `claim_number`?: oneOf, `external_id`?: oneOf, `id`: `uuid.Uuid`, `images`: `ImageV1`[], `job_number`?: oneOf, `repairer_site_id`: `uuid.Uuid`, `vehicle`?: oneOf, `work_provider_id`?: oneOf | | **Errors** | `unauthorized`, `forbidden`, `not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.get" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"identity":{"external":"CCC-2026-04817"}}' ``` ### `repairer.jobs.insert` Inserts a new repair job or updates an existing repair job. | | | |---|---| | **Request** | `JobsInsertRequestV1` — required: `repairer_site_id`, `identity` | | **Response** | `JobV1` — `claim_number`?: oneOf, `external_id`?: oneOf, `id`: `uuid.Uuid`, `images`: `ImageV1`[], `job_number`?: oneOf, `repairer_site_id`: `uuid.Uuid`, `vehicle`?: oneOf, `work_provider_id`?: oneOf | | **Errors** | `unauthorized`, `forbidden`, `unprocessable`, `work_provider_not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.insert" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"identity":{"external":"CCC-2026-04817"},"repairer_site_id":"0b000000-0000-4000-8000-000000000001","work_provider_id":"0a000000-0000-4000-8000-000000000020"}' ``` ### `repairer.jobs.parts.insert` Inserts parts for a repair job. | | | |---|---| | **Request** | `JobsPartsInsertRequestV1` — required: `job_identity`, `parts` | | **Response** | `JobsPartsListResponseV1` — `items`: `PartV1`[] | | **Errors** | `unauthorized`, `forbidden`, `not_found`, `unprocessable` — **bare JSON strings**, not `{type}` objects (D24) | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.parts.insert" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"job_identity":{"external":"CCC-2026-04817"},"parts":[{"description":"Front bumper cover assembly","quantity":1}]}' ``` ### `repairer.jobs.parts.list` Lists all parts for a repair job. | | | |---|---| | **Request** | `JobsPartsListRequestV1` — required: `job_identity` | | **Response** | `JobsPartsListResponseV1` — `items`: `PartV1`[] | | **Errors** | `unauthorized`, `forbidden`, `not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.jobs.parts.list" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"job_identity":{"external":"CCC-2026-04817"}}' ``` ### `repairer.procurements.get` Fetch a procurement for a repairer by ID | | | |---|---| | **Request** | `_(inline)_` — required: `identity` | | **Response** | `procurements.v1.buyer.Procurement` — `created_at`: string, `currency_code`: oneOf, `id`: `uuid.Uuid`, `job_id`: `uuid.Uuid`, `reference`: oneOf, `source_items`: `procurements.v1.buyer.SourceItem`[], `supplier_business`: `procurements.v1.buyer.SupplierBusiness`, `supplier_organization`: `procurements.v1.buyer.SupplierOrganization`, `total_shipping_price`: oneOf, `updated_at`: string, `vehicle`: `procurements.v1.Vehicle` | | **Errors** | `unauthorized`, `forbidden`, `not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.get" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"identity":{"id":"10000000-0000-4000-8000-000000000001"}}' ``` ### `repairer.procurements.list` List procurements for a repairer | | | |---|---| | **Request** | `procurements.v1.repairer.ProcurementsListRequest` — required: `job_identity` | | **Response** | `procurements.v1.buyer.ProcurementsListResponse` — `items`: `procurements.v1.buyer.Procurement`[] | | **Errors** | `unauthorized`, `forbidden`, `not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.list" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"job_identity":{"external":"CCC-2026-04817"}}' ``` ### `repairer.work-providers.list` Fetches the list of work providers for a repairer. | | | |---|---| | **Request** | `WorkProvidersListRequestV1` — required: _none_ | | **Response** | `WorkProvidersListResponseV1` — `items`: `WorkProviderV1`[] | | **Errors** | `unauthorized`, `forbidden` | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.work-providers.list" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{}' ``` ### `supplier.procurements.confirm` Transition a procurement to order confirmed | | | |---|---| | **Request** | `_(inline)_` — required: `identity` | | **Response** | `procurements.v1.supplier.Procurement` — `buyer_business`: `procurements.v1.supplier.BuyerBusiness`, `buyer_organization`: `procurements.v1.supplier.BuyerOrganization`, `buyer_shipping_address`: `procurements.v1.Address`, `created_at`: string, `currency_code`: oneOf, `id`: `uuid.Uuid`, `job_id`: `uuid.Uuid`, `reference`: oneOf, `source_items`: `procurements.v1.supplier.SourceItem`[], `total_shipping_price`: oneOf, `updated_at`: string, `vehicle`: `procurements.v1.Vehicle` | | **Errors** | `unauthorized`, `forbidden`, `not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/supplier.procurements.confirm" \ -H "Authorization: Bearer partly_demo_supplier_8b4e2f1a6c0d3e9f7a25" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000002" \ -H "Content-Type: application/json" \ -d '{"identity":{"id":"10000000-0000-4000-8000-000000000001"}}' ``` ### `supplier.procurements.get` Fetch a procurement for a supplier by ID | | | |---|---| | **Request** | `_(inline)_` — required: `identity` | | **Response** | `procurements.v1.supplier.Procurement` — `buyer_business`: `procurements.v1.supplier.BuyerBusiness`, `buyer_organization`: `procurements.v1.supplier.BuyerOrganization`, `buyer_shipping_address`: `procurements.v1.Address`, `created_at`: string, `currency_code`: oneOf, `id`: `uuid.Uuid`, `job_id`: `uuid.Uuid`, `reference`: oneOf, `source_items`: `procurements.v1.supplier.SourceItem`[], `total_shipping_price`: oneOf, `updated_at`: string, `vehicle`: `procurements.v1.Vehicle` | | **Errors** | `unauthorized`, `forbidden`, `not_found` | ```bash curl -sS -X POST "$BASE/api/2026-01/supplier.procurements.get" \ -H "Authorization: Bearer partly_demo_supplier_8b4e2f1a6c0d3e9f7a25" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000002" \ -H "Content-Type: application/json" \ -d '{"identity":{"id":"10000000-0000-4000-8000-000000000001"}}' ``` ### `tier1.prepare` Queue up a given vehicle VIN for future use in other services. | | | |---|---| | **Request** | `Tier1PrepareRequestV1` — required: `identifiers` | | **Response** | `Tier1PrepareResponseV1` — `invalid_identifiers`: `InvalidIdentifierV1`[] | | **Errors** | `unauthorized` | ```bash curl -sS -X POST "$BASE/api/2026-01/tier1.prepare" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"identifiers":[{"vin":"JTDBR32E730012345"}]}' ``` ### `uploads.prepare` Generate a pre-signed URL for uploading a file. | | | |---|---| | **Request** | `UploadsPrepareRequestV1` — required: `file_name`, `media_type`, `type` | | **Response** | `UploadsPrepareResponseV1` — `public_url`: oneOf, `upload_url`: `std.string.String` | | **Errors** | `unauthorized`, `forbidden`, `unprocessable` | ```bash curl -sS -X POST "$BASE/api/2026-01/uploads.prepare" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"file_name":"estimate.jpg","media_type":"image","type":"job_media"}' ``` --- ## Proposed extensions (NOT in 2026-01) These three methods are **not** part of the public `2026-01` contract and are **not** in [`/openapi.json`](/openapi.json). the upstream platform performs procurement-placement and invoice-reconciliation internally but exposes no public method; here they are reconstructed as a clearly-labelled proposed extension so the buyer loop is demonstrable end to end. Every extension response carries `x_extension: true`. See [Lifecycle](./lifecycle.md). ### `repairer.procurements.insert` _(MCP tool: `place_procurement` ⚠)_ > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) Creates a procurement in `order_requested` from selected basket offers and moves the chosen parts `estimated → ordered`. Its response carries `"x_extension": true`. | | | |---|---| | **Request** | `job_identity` + `offer_selections[]` (each `{ sellable_id, supplier_business_id, job_part_ids, quantity }`) | | **Response** | `RepairerProcurementsInsertResponseV1` (local schema; `x_extension: true`) | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.insert" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"job_identity":{"external":"CCC-2026-04817"},"offer_selections":[{"sellable_id":"0f000000-0000-4000-8000-000000000007","supplier_business_id":"0b000000-0000-4000-8000-000000000010","job_part_ids":["0e000000-0000-4000-8000-000000000004"],"quantity":1}]}' ``` ### `repairer.procurements.invoices.list` _(MCP tool: `reconcile_invoice` ⚠)_ > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) Lists the reconciled invoices and credit notes for a procurement (or job). Invoices appear only after `order_confirmed`. Its response carries `"x_extension": true`. | | | |---|---| | **Request** | exactly one selector — `procurement_identity` XOR `job_identity` | | **Response** | `RepairerProcurementsInvoicesListResponseV1` (local schema; `x_extension: true`) | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.invoices.list" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"procurement_identity":{"id":"10000000-0000-4000-8000-000000000001"}}' ``` ### `repairer.procurements.invoices.get` > (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.) Fetches a single invoice or credit note by its own id (obtain the id from `repairer.procurements.invoices.list`). Its response carries `"x_extension": true`. | | | |---|---| | **Request** | `identity` — `{ id: }` | | **Response** | `RepairerProcurementsInvoicesGetResponseV1` (local schema; `x_extension: true`) | ```bash curl -sS -X POST "$BASE/api/2026-01/repairer.procurements.invoices.get" \ -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \ -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \ -H "Content-Type: application/json" \ -d '{"identity":{"id":""}}' ```