> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vaultgraph.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# VaultGraph remote commerce adapter protocol

> Webhook wire contract between VaultGraph and merchant-hosted commerce backends

> **Direction:** VaultGraph → merchant. Your storefront exposes **one HTTPS endpoint**; the gateway POSTs to it on every tool invocation. Your storefront never calls VaultGraph.

**Status:** v1.0 (stable). Backwards-incompatible changes will bump the major version.

This document is the canonical reference for merchants implementing a VaultGraph commerce backend over HTTPS. It defines the request/response envelope, signing scheme, error model, idempotency contract, retry/timeout behavior, and pagination convention.

The [`@vaultgraph/sdk/adapter`](https://www.npmjs.com/package/@vaultgraph/sdk) helper wraps everything below in a single typed factory — most merchants should use it instead of implementing the wire contract by hand.

***

## 1. Transport

* **Direction:** VaultGraph → merchant. The merchant exposes one HTTPS endpoint; the gateway calls it.
* **Method:** `POST`.
* **URL shape:** `{endpoint_url}/{method}` — the configured `endpoint_url` joined with the method name as a single path segment. The path prefix is the merchant's choice — VaultGraph does not reserve or require any particular name. Example: `https://shop.example.com/webhooks/vaultgraph/createCheckout`.
* **TLS:** required. The gateway will not call plain `http://` endpoints.
* **Content-Type:** `application/json; charset=utf-8` on both directions.

***

## 2. Request envelope

```json theme={null}
{
  "protocol": "1.0",
  "method": "createCheckout",
  "params": { "input": { "currency": "GBP" } }
}
```

| Field      | Type     | Notes                                                                                                                                          |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `protocol` | `string` | Wire protocol version. Currently `"1.0"`. Merchants MUST reject requests with an unsupported `protocol`.                                       |
| `method`   | `string` | One of the methods in §6. Mirrors the path segment.                                                                                            |
| `params`   | `object` | Method-specific arguments. Always an object (never an array or primitive).                                                                     |
| `context`  | `object` | Optional `string → string` map of caller-supplied request context — see [Request context](#request-context). Omitted when the caller set none. |

### Request context

Requests carry opaque key/value context — most importantly **customer identity** — which the gateway forwards verbatim as the envelope's `context` so your endpoint can scope reads (most importantly `listOrders`) and attribute writes. Context enters a request in two ways:

* **Transport headers.** A caller that controls its HTTP transport sets `x-vg-ctx-*` headers on its MCP or REST requests. The gateway lower-cases each name, strips the prefix, and maps hyphens to underscores:

  ```text theme={null}
  x-vg-ctx-customer: cus_42            →   "context": { "customer": "cus_42" }
  x-vg-ctx-session-token: <token>      →   "context": { "session_token": "<token>" }
  ```

  Use hyphens in header names: some proxies (e.g. nginx's default config) silently drop header fields containing underscores. Key names are otherwise yours to choose; the gateway never reads, validates, or adds entries.

* **The `session_token` tool argument.** Every commerce tool accepts an optional opaque `session_token` string — the path for agent platforms whose connector configuration is static (no per-conversation headers). Supply the value to the conversation (e.g. as a `vg_session` variable the agent is instructed to copy verbatim), and the gateway strips it from the tool input — it never appears in `params` — and forwards it as `context.session_token`. The token itself is provisioned by your application outside the conversation, or minted mid-conversation via [Customer sign-in](#customer-sign-in).

`context` is part of the signed body (§3), so it cannot be altered between VaultGraph and you. It is asserted, not authenticated: anyone holding the deployment API key can claim any value, and a `session_token` additionally travels through the agent conversation. Make the value verifiable — a short-lived signed token bound to the customer and an expiry, not a bare customer id — and verify it in your handler before trusting it.

The typed `RequestContext` ships from [`@vaultgraph/sdk/adapter`](https://www.npmjs.com/package/@vaultgraph/sdk): a `session_token?: string` named field plus an open `[key: string]: string | undefined` index for the other keys you chose.

#### Authorizing owner-scoped reads

`getOrder`, `getCheckout`, and `listOrders` return data that belongs to one customer. The ids an agent holds are asserted, not secret, so authorize every such read against a verified caller:

1. Verify `context.session_token` and resolve the owning customer. An unverifiable or absent token is an anonymous caller.
2. Confirm that customer owns the requested resource, then signal the outcome with whatever error you choose (§7) — e.g. throw `order_not_found` (404) when the id is unknown or not the caller's, or `authentication_required` (401) for `listOrders` from an anonymous caller so the agent can prompt the user to sign in. (An authenticated customer who genuinely has no orders still returns an empty page, `{ "orders": [] }`.)

> Best practice (optional): for single-resource reads, throw the **same** `*_not_found` whether the id is unknown or simply not the caller's — a distinct "exists but forbidden" answer lets a caller enumerate ids they don't own.

The `@vaultgraph/sdk/adapter` handler turns step 1 into a single hook. Pass `verifySession(context)` to `createAdapterHandler`; it runs once per request, and its return value — your verified identity, of any shape, or `null` for an anonymous caller — is handed to every method as the final `session` argument. The hook is the sole consumer of `session_token`: the handler strips it from the context methods receive, so owner-scoped reads gate on `session` directly and a method can't trust the raw token by mistake.

### Headers

| Header                         | Required                 | Notes                                                                                                                |
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`                 | yes                      | `application/json`.                                                                                                  |
| `User-Agent`                   | yes                      | Stable identifier `VaultGraph-Commerce/<version>`. Allowlist it at your WAF — see [Bot protection](#bot-protection). |
| `X-VaultGraph-Version`         | yes                      | Same value as `protocol` in the body. Lets edge proxies short-circuit rejection.                                     |
| `X-VaultGraph-Signature`       | yes                      | See §3.                                                                                                              |
| `X-VaultGraph-Idempotency-Key` | on mutating methods (§5) | UUID v4 generated per logical operation by the gateway.                                                              |

### Bot protection

Gateway requests originate from datacenter IPs with the `User-Agent` above. WAF and bot-filtering products (e.g. Cloudflare Super Bot Fight Mode) may classify such traffic as a bot and answer with an HTML challenge or `403`, which the gateway surfaces as a failed connection. If your endpoint sits behind one, exempt the adapter path: add a rule that **skips** bot mitigation for requests to your `{endpoint_url}` path that carry the `X-VaultGraph-Signature` header.

***

## 3. Signing

Authentication is HMAC-SHA256 over `${timestamp}.${idempotencyKey}.${rawBody}`.

1. The gateway takes the current Unix timestamp in **seconds**.
2. It computes `signature = HMAC_SHA256(secret, "${timestamp}.${idempotencyKey}.${rawBody}")` and hex-encodes it. `${idempotencyKey}` is the value of the `X-VaultGraph-Idempotency-Key` header (§5); on non-mutating methods, which carry no idempotency key, it is the **empty string**.
3. It sets `X-VaultGraph-Signature: t=<timestamp>,v1=<hex_signature>`.

Merchants MUST:

1. Reject requests where `X-VaultGraph-Signature` is missing or malformed.
2. Reject requests where `|now - timestamp| > 5 minutes`. This is the **replay window**.
3. Recompute the HMAC over the timestamp, the `X-VaultGraph-Idempotency-Key` header value (empty string when absent), and the raw body bytes (not a re-serialized JSON), and reject on mismatch. Use a constant-time comparison.

The `t=…,v1=…` shape leaves room for `v2` to ship a new digest in the future without breaking existing verifiers.

### Secret rotation

VaultGraph issues the signing secret when you create the commerce backend in the portal and lets you rotate it from the backend's settings page. After a rotation the gateway begins signing with the new secret within 60 seconds. The protocol does not multiplex signatures, so coordinating a dual-accept window on your verifier (accept both old and new during the cutover) is the merchant's responsibility.

***

## 4. Response envelope

Success:

```json theme={null}
{
  "data": {/* method-specific result */}
}
```

Failure:

```json theme={null}
{
  "error": {
    "code": "checkout_not_open",
    "status": 409,
    "detail": "Checkout ck_abc has already completed."
  }
}
```

| Field          | Type      | Notes                                                                                         |
| -------------- | --------- | --------------------------------------------------------------------------------------------- |
| `data`         | `any`     | Required on success. Method-specific shape (§6).                                              |
| `error.code`   | `string`  | Required on failure. Any string you choose — forwarded to the agent. See §7.                  |
| `error.status` | `integer` | Optional. HTTP-style status the gateway should surface. Defaults to the HTTP response status. |
| `error.detail` | `string`  | Optional. Human-readable message safe for upstream display; do not include secrets or PII.    |

The HTTP status code SHOULD mirror `error.status`. Returning a 2xx with an `error` field is tolerated (the gateway prefers the envelope) but discouraged.

A response with neither `data` nor `error` — or an HTTP error with no JSON body — is treated as `commerce_backend_unavailable` (HTTP 502).

***

## 5. Idempotency

The gateway sends `X-VaultGraph-Idempotency-Key: <uuid>` on every **mutating** method:

* `createCheckout`
* `addLineItems`
* `updateLineItem`
* `removeLineItem`
* `applyDiscount`
* `setFulfillment`
* `setBuyer`
* `completeCheckout`
* `requestAuthentication`
* `verifyAuthentication`

Reads (`searchProducts`, `getProduct`, `listCategories`, `getCheckout`, `listFulfillmentMethods`, `getOrder`, `listOrders`) do **not** carry an idempotency key.

The contract:

* The merchant SHOULD store the full response (status + body) keyed by the idempotency key.
* If the same key arrives a second time within the merchant's TTL, the merchant SHOULD return the stored response verbatim instead of re-running the operation.
* **Cache both success AND error responses.** A mutating call that fails after committing a side effect (e.g. order written, downstream notification failed) must NOT re-execute on retry. The gateway treats a well-formed error envelope as a deterministic answer and will not retry it, but a network blip mid-response can still cause a retry — the cache is what protects against the duplicate.
* **Recommended TTL: 24 hours.** Retries beyond that window are vanishingly rare; storage costs grow if you keep them forever.

The SDK helper exposes an `idempotencyStore` option that handles this for you given a `{ get(key), set(key, { status, body }) }` interface — most merchants will plug in their existing cache layer (Redis, Postgres unlogged table, KV).

***

## 6. Methods

| Method                   | Mutating | `params`                                                                                         | `data`                                                                                                                                         |
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `searchProducts`         | no       | `{ input: SearchCatalogInput }` (see [Catalog search](#catalog-search))                          | `ProductPage` (`{ products: Product[], pagination?: { … } }`)                                                                                  |
| `getProduct`             | no       | `{ id: string }`                                                                                 | `Product`. Throw `product_not_found` (404) when no product matches.                                                                            |
| `listCategories`         | no       | `{}`                                                                                             | `Category[]` (`{ value: string, count?: integer }[]`)                                                                                          |
| `createCheckout`         | yes      | `{ input: CheckoutCreateInput }`                                                                 | `Checkout`                                                                                                                                     |
| `getCheckout`            | no       | `{ id: string }`                                                                                 | `Checkout`. Throw `checkout_not_found` (404). Owner-scoped — see [Authorizing owner-scoped reads](#authorizing-owner-scoped-reads).            |
| `addLineItems`           | yes      | `{ checkout_id: string, line_items: LineItem[] }`                                                | `Checkout`                                                                                                                                     |
| `updateLineItem`         | yes      | `{ checkout_id: string, line_item_id: string, quantity: integer ≥ 0 }`                           | `Checkout`                                                                                                                                     |
| `removeLineItem`         | yes      | `{ checkout_id: string, line_item_id: string }`                                                  | `Checkout`                                                                                                                                     |
| `applyDiscount`          | yes      | `{ checkout_id: string, code: string }`                                                          | `Checkout`                                                                                                                                     |
| `listFulfillmentMethods` | no       | `{ checkout_id: string }`                                                                        | `FulfillmentMethod[]` (see [Fulfillment](#fulfillment))                                                                                        |
| `setFulfillment`         | yes      | `{ checkout_id: string, fulfillment_method_id: string, shipping_address?: ShippingDestination }` | `Checkout`                                                                                                                                     |
| `setBuyer`               | yes      | `{ checkout_id: string, buyer: Buyer, billing_address?: BillingAddress }`                        | `Checkout`                                                                                                                                     |
| `completeCheckout`       | yes      | `{ checkout_id: string }`                                                                        | `Order`                                                                                                                                        |
| `getOrder`               | no       | `{ id: string }`                                                                                 | `Order`. Throw `order_not_found` (404). Owner-scoped — see [Authorizing owner-scoped reads](#authorizing-owner-scoped-reads).                  |
| `listOrders`             | no       | `{ input: ListOrdersInput }` (see [Pagination](#pagination))                                     | `OrderPage` (`{ orders: Order[], pagination?: { … } }`). Owner-scoped — see [Authorizing owner-scoped reads](#authorizing-owner-scoped-reads). |
| `requestAuthentication`  | yes      | `{ email: string }`                                                                              | `AuthChallenge` (`{ challenge_id: string }`) — see [Customer sign-in](#customer-sign-in).                                                      |
| `verifyAuthentication`   | yes      | `{ challenge_id: string, code: string }`                                                         | `AuthSession` (`{ session_token: string, expires_at?: string }`) — see [Customer sign-in](#customer-sign-in).                                  |

TypeScript types for every `params` and `data` shape ship from [`@vaultgraph/sdk/adapter`](https://www.npmjs.com/package/@vaultgraph/sdk) — install it for types even if you don't use the handler factory in §10. The examples below show each shape with realistic values; the types remain the exhaustive reference. All money is integer minor units (e.g. `12900` = £129.00) paired with an ISO 4217 `currency`.

### Data shapes

#### Inputs

`CheckoutCreateInput` — `createCheckout`. `line_items` may be empty; `buyer` is optional; `payment` may be `{}`:

```jsonc theme={null}
{
  "currency": "GBP",
  "line_items": [{ "item": { "id": "var_ridge_32l" }, "quantity": 2 }],
  "buyer": { "email": "alice@example.com", "full_name": "Alice Smith" },
  "payment": {},
}
```

`LineItem` — the element type of `addLineItems`. Send the variant id and quantity; `id` is optional (the backend assigns one). A line may also carry `properties`: an optional array of `{ name, value }` custom fields you define (e.g. an engraving, a gift message, a monogram). The gateway never interprets them — it stores them on the line and echoes them on the `Checkout` and `Order` — so you own their meaning. Two lines of the same variant with different `properties` are distinct lines, not a quantity merge:

```jsonc theme={null}
{
  "item": { "id": "var_ridge_32l" },
  "quantity": 2,
  "properties": [
    { "name": "Engraving", "value": "Best Dad Ever" },
    { "name": "Gift wrap", "value": "Yes" },
  ],
}
```

`Buyer` + optional `BillingAddress` — `setBuyer`. A buyer `email` is what later unlocks completion:

```jsonc theme={null}
{
  "buyer": {
    "email": "alice@example.com",
    "full_name": "Alice Smith",
    "phone_number": "+447911123456",
  },
  "billing_address": {
    "full_name": "Alice Smith",
    "street_address": "42 Baker Street",
    "address_locality": "London",
    "postal_code": "NW1 6XE",
    "address_country": "GB",
  },
}
```

`ShippingDestination` — `setFulfillment`'s `shipping_address` (required for `shipping` methods; see [Fulfillment](#fulfillment)):

```json theme={null}
{
  "full_name": "Alice Smith",
  "street_address": "10 Test Street",
  "address_locality": "London",
  "postal_code": "EC1A 1AA",
  "address_country": "GB"
}
```

`SearchCatalogInput` — `searchProducts`, all fields optional — see [Catalog search](#catalog-search).

#### Outputs

`Checkout` — returned by `createCheckout` and every checkout mutation. Returned `line_items` are **enriched** (each `item` carries `price`/`title`/`image_url` and a per-line `totals`) — unlike the bare `LineItem` you send in. `totals` is the cart-level breakdown; include each row that applies (`subtotal`, `discount`, `fulfillment`, `tax`, `total`):

```jsonc theme={null}
{
  "id": "ck_abc123",
  "currency": "GBP",
  "status": "ready_for_complete", // see Checkout lifecycle below
  "line_items": [
    {
      "id": "li_1",
      "item": {
        "id": "var_ridge_32l",
        "price": 12900,
        "title": "Ridge Backpack (32L)",
        "image_url": "https://cdn.example.com/ridge.png",
      },
      "quantity": 2,
      "totals": [{ "type": "subtotal", "amount": 25800 }],
    },
  ],
  "totals": [
    { "type": "subtotal", "amount": 25800 },
    { "type": "discount", "amount": 1000, "display_text": "WELCOME10" },
    {
      "type": "fulfillment",
      "amount": 500,
      "display_text": "Standard shipping",
    },
    { "type": "tax", "amount": 1984 },
    { "type": "total", "amount": 27284 },
  ],
  "buyer": { "email": "alice@example.com", "full_name": "Alice Smith" },
  "payment": { "handlers": [], "instruments": [] },
}
```

`Order` — returned by `completeCheckout` and `getOrder`. Like a `Checkout` plus a `checkout_id`, a `permalink_url`, a `created_at` placement timestamp, an optional `display_id`, an optional order-level `status` and `payment_status`, per-line fulfillment `status` + `quantity` tracking, a `fulfillment` block, and optional `messages`. `display_id` is the short, customer-facing order reference (use for display only, not for lookups). The order `status` is `processing`, `completed`, or `cancelled`; `payment_status` is `pending`, `authorized`, `paid`, `partially_refunded`, `refunded`, or `failed` — omit either when you do not track it. A line's `status` is `processing`, `partial`, or `fulfilled`.

`fulfillment` holds two lists. `expectations` is the plan — one entry per shipment, each carrying how it ships (`method`: `{ id, name, method_type }`) and its `destination` (where the goods physically go); split an order into several expectations when its lines go out separately. `destination` is the shopper's address for `shipping` and the collection point's address (store or carrier locker) for `pickup`, and is `{}` only for `digital`; its optional `instructions` is free text for the shopper about the destination (e.g. "InPost locker no. 345", "Click & collect in store"). `events` is the history — milestones that have already happened (`dispatched`, `delivered`, …), each with an `occurred_at` and, once handed to a carrier, `carrier` / `tracking_number` / `tracking_url`. There is no fulfillment charge inside this block — it is a line in `totals`.

```jsonc theme={null}
{
  "id": "ord_def456",
  "display_id": "ORD_1042",
  "created_at": "2026-06-23T10:15:00Z",
  "checkout_id": "ck_abc123",
  "currency": "GBP",
  "permalink_url": "https://shop.example.com/orders/ord_def456",
  "status": "processing",
  "payment_status": "paid",
  "line_items": [
    {
      "id": "li_1",
      "status": "processing",
      "quantity": { "total": 2, "fulfilled": 0 },
      "item": {
        "id": "var_ridge_32l",
        "price": 12900,
        "title": "Ridge Backpack (32L)",
      },
      "totals": [{ "type": "subtotal", "amount": 25800 }],
    },
  ],
  "totals": [
    { "type": "subtotal", "amount": 25800 },
    { "type": "fulfillment", "amount": 500 },
    { "type": "total", "amount": 27284 },
  ],
  "fulfillment": {
    "expectations": [
      {
        "id": "fe_ord_def456",
        "method": {
          "id": "standard",
          "name": "Standard shipping",
          "method_type": "shipping",
        },
        "line_items": [{ "id": "li_1", "quantity": 2 }],
        "destination": {
          "full_name": "Alice Smith",
          "street_address": "10 Test Street",
          "address_locality": "London",
          "postal_code": "EC1A 1AA",
          "address_country": "GB",
        },
        "fulfillable_on": "2026-06-25",
      },
    ],
    "events": [
      {
        "id": "fev_1",
        "type": "dispatched",
        "occurred_at": "2026-06-25T09:00:00Z",
        "line_items": [{ "id": "li_1", "quantity": 2 }],
        "carrier": "Royal Mail",
        "tracking_number": "RM123456789GB",
      },
    ],
  },
  "messages": [
    {
      "id": "msg_1",
      "body": "Your order has shipped.",
      "created_at": "2026-06-25T09:01:00Z",
    },
  ],
}
```

A `pickup` expectation puts the collection point's address in `destination`, with `instructions` telling the shopper where to go:

```jsonc theme={null}
{
  "id": "fe_ord_ghi789",
  "method": { "id": "locker", "name": "InPost locker", "method_type": "pickup" },
  "line_items": [{ "id": "li_1", "quantity": 1 }],
  "destination": {
    "street_address": "12 Old Street",
    "address_locality": "London",
    "postal_code": "EC1V 9BD",
    "address_country": "GB",
    "instructions": "InPost locker no. 345",
  },
}
```

`OrderPage` — returned by `listOrders`: `{ "orders": Order[], "pagination"?: { … } }` — see [Pagination](#pagination).

`Product` — returned by `getProduct` and inside `ProductPage.products`. A product groups one or more purchasable `Variant`s; a variant's `id` is what goes in a `LineItem`'s `item.id`. Product-level `media` is the optional gallery / fallback; set a variant's own `media` when variants differ visually (e.g. colorways) so agents can render the chosen variant. The gateway flattens a top-level `image_url` onto the product (its first `image`-typed media) and onto each variant (the variant's first image, falling back to the product's) in the response it serves to agents, so you normally leave `image_url` unset; if you do set it, the gateway keeps your value verbatim as an override — and an image-less product can omit `media` entirely:

```jsonc theme={null}
{
  "id": "prod_ridge",
  "title": "Ridge Backpack",
  "brand": "Ridge", // optional
  "description": "32L technical daypack.",
  "media": [{ "type": "image", "url": "https://cdn.example.com/ridge.png" }], // optional
  "categories": ["bags"],
  "url": "https://shop.example.com/products/ridge-backpack", // optional
  "price_range": {
    "min": { "amount": 12900, "currency": "GBP" },
    "max": { "amount": 12900, "currency": "GBP" },
  },
  "variants": [
    {
      "id": "var_ridge_32l",
      "product_id": "prod_ridge",
      "title": "32L",
      "sku": "RB-32",
      "price": { "amount": 12900, "currency": "GBP" },
      "availability": { "available": true },
      "media": [
        { "type": "image", "url": "https://cdn.example.com/ridge-32l.png" },
      ], // optional
      "url": "https://shop.example.com/products/ridge-backpack?variant=32l", // optional
    },
  ],
}
```

`ProductPage` — returned by `searchProducts`: `{ "products": Product[], "pagination"?: { … } }` — see [Pagination](#pagination).

`Category` — the element type of `listCategories`: `{ "value": string, "count"?: integer }`. `value` is a category label usable verbatim in `SearchCatalogInput.filters.categories`; the optional `count` is store-global (every product carrying the category), never scoped to a search. Return the store's whole vocabulary so an agent can discover what categories exist and recover from a search that matched nothing.

`FulfillmentMethod` — the element type of `listFulfillmentMethods` — see [Fulfillment](#fulfillment).

### Checkout lifecycle

A checkout carries a `status` that moves through three states:

* `incomplete` — the state on `createCheckout`, and any time the checkout is missing something completion needs.
* `ready_for_complete` — reached once the checkout has **all** of: at least one line item, a buyer with an email (`setBuyer`), and a fulfillment method (`setFulfillment`).
* `completed` — set by `completeCheckout`; the checkout is now an order and no longer mutable.

Recompute `status` on every mutation: it is `ready_for_complete` exactly when the three conditions hold, otherwise `incomplete` — e.g. removing the last line item drops a checkout back to `incomplete`.

`completeCheckout` succeeds only from `ready_for_complete`, returning the `Order`. It rejects:

* an empty cart with `checkout_empty`,
* a cart still missing a buyer email or fulfillment method with `checkout_not_ready`,
* an already-`completed` cart with `checkout_not_open`.

### Cart mutations

* `addLineItems` merges by `item.id` **and** `properties`: adding an item already in the cart with the same properties increases that line's quantity; the same variant with different properties becomes a separate line.
* `updateLineItem` with `quantity: 0` removes the line — equivalent to `removeLineItem`.

### Catalog search

`searchProducts` takes `{ input: SearchCatalogInput }`, where every field of `SearchCatalogInput` is optional — an empty `input` returns the first page of the whole catalog:

```jsonc theme={null}
{
  "query": "running shoes", // optional free-text search
  "filters": {
    // optional
    "categories": ["footwear"], // OR logic across the list
    "price": { "min": 5000, "max": 20000 }, // inclusive, integer minor units
  },
  "pagination": { "cursor": "…", "limit": 20 }, // optional; limit is clamped to ≤ 100
}
```

The merchant SHOULD honor `query` and `filters`; a merchant that cannot run a given filter MAY ignore it, but SHOULD NOT error.

### Pagination

`searchProducts` returns a **`ProductPage`** (`{ "products": Product[], "pagination"?: { … } }`) and `listOrders` returns an **`OrderPage`** (`{ "orders": Order[], "pagination"?: { … } }`) — neither is a bare array. Both share one cursor-based, caller-driven convention:

* The merchant chooses the cursor encoding (offset, keyset, base64 JSON — whatever fits the underlying store). The gateway treats it as an opaque blob: it never inspects or generates the value.
* When more pages remain, the merchant returns `pagination: { "has_next_page": true, "cursor": "<token>" }`. `cursor` MUST be present whenever `has_next_page` is true.
* On the last page the merchant either omits `pagination` entirely or returns `pagination: { "has_next_page": false }`. `total_count` MAY be included on any page.
* The caller passes the returned `cursor` straight back as `input.pagination.cursor` on the next call; the gateway forwards it unchanged. `input.pagination.limit` must be ≤ 100 — the gateway rejects a larger value before the call reaches the merchant.
* The gateway does **not** auto-paginate — each call returns exactly one page and the caller drives the loop.

For `listOrders`, page on top of the owner-scoping: resolve the caller's customer from the request's [`context`](#request-context) first, then page that customer's orders.

### Fulfillment

`listFulfillmentMethods` returns the methods selectable for a checkout — each a **`FulfillmentMethod`** (`{ id, name, description, amount, currency, method_type }`), where `amount` is integer minor units and `method_type` is `shipping`, `pickup`, or `digital`.

`setFulfillment` accepts an optional `shipping_address` (a `ShippingDestination` — a postal address). When the chosen method's `method_type` is `shipping`, a deliverable destination is required: `street_address`, `address_locality`, `postal_code`, and `address_country` (ISO 3166-1 alpha-2, e.g. `GB`, `US`) must all be present. Reject a missing or incomplete address with `fulfillment_address_required` (400). `pickup` and `digital` methods carry no address.

### Customer sign-in

`requestAuthentication` and `verifyAuthentication` let a customer sign in **mid-conversation**, so an agent can recover from `authentication_required` (see [Authorizing owner-scoped reads](#authorizing-owner-scoped-reads)) without leaving the chat:

1. `requestAuthentication` — `{ email: string }`. Deliver a one-time code out of band (e.g. to the customer's verified email) and return an `AuthChallenge`: `{ "challenge_id": "<opaque>" }`. You own the id encoding and where the pending challenge lives.
2. The customer reads the code and gives it to the agent.
3. `verifyAuthentication` — `{ challenge_id: string, code: string }`. Check the code against the challenge and return an `AuthSession`: `{ "session_token": "<opaque>", "expires_at"?: "<ISO 8601>" }`. Reject a wrong code with your own 4xx (e.g. `invalid_code`, 401).
4. The agent sends the token on the customer's later calls; it reaches you as [`context.session_token`](#request-context), where your `verifySession` hook authenticates it — exactly as a token provisioned outside the conversation would.

Both methods are mutating (sending a code and minting a token are side effects), so they carry an idempotency key (§5).

Security is yours to own; the gateway forwards, it never inspects:

* **Don't leak which emails have accounts.** Respond to an unknown email exactly as to a known one — return a `challenge_id` and simply skip the send. A distinct error is an account-enumeration oracle.
* **Rate-limit** code requests per email and per caller.
* **Cap attempts and expire fast.** A handful of wrong codes should kill the challenge; a challenge should live minutes, not hours. Make challenges single-use.
* **Scope the token down.** Both the code and the token travel through the agent conversation, so mint a short-lived token good for the least number of required features only.

***

## 7. Error codes

You own your error codes. Emit any `error.code` with a **4xx** `error.status` and the gateway forwards it to the agent **verbatim** — `code`, `status`, and `detail` unchanged. Pick codes your agents can act on.

```json theme={null}
{
  "error": {
    "code": "store_closed",
    "status": 403,
    "detail": "The store is closed for the holiday."
  }
}
```

The agent sees the `detail` string (falling back to the `code` when you omit `detail`), so write `detail` for a human reader and never put secrets or PII in it.

**Gateway-owned codes.** Any **5xx** handling is on the VaultGraph gateway side (not the merchant side): a **5xx** `error.status` — or a missing/out-of-range one — is normalized to `commerce_backend_unavailable` (HTTP 502). The same applies to handshake/dispatch codes (`invalid_signature`, `unsupported_protocol`, `unknown_method`, `idempotency_key_required`, `not_implemented`, `internal_error`) at any status — the SDK raises these around dispatch, not as a business answer — and to a response with neither `data` nor `error`, a non-JSON body, a transport failure, or a timeout (§4, §8). On all of these the gateway also **drops your `detail`** and substitutes a generic message — a normalized error's `detail` is diagnostic (often an exception dump), not an agent-facing message, so only a deliberate 4xx `detail` reaches the agent.

***

## 8. Timeout & retry

The gateway defaults:

* **Per-attempt timeout:** 10 seconds (configurable per backend in the portal).
* **Retries:** up to 2 (configurable per backend in the portal), with exponential backoff starting at 100 ms.

Retries fire on:

* network errors (DNS, TCP, TLS, socket close mid-response),
* response timeouts (the request aborted before headers),
* HTTP 5xx **without** a recognized error envelope.

Retries do **not** fire on a well-formed error envelope — a 4xx is forwarded verbatim and a 5xx normalizes to `commerce_backend_unavailable`, both deterministic answers. They also do not fire on 4xx without an envelope (treated as a 502 once, then surfaced).

Because every mutating method carries an `X-VaultGraph-Idempotency-Key`, the merchant can safely dedupe. Reads are naturally idempotent.

***

## 9. Versioning

* **Envelope version** is the `protocol` field plus the `X-VaultGraph-Version` header. Additive payload changes (new methods, new optional fields on `data`) ship under the same envelope.
* Backwards-incompatible envelope changes will bump the major version (`1.0` → `2.0`) and use a new header value. The gateway will continue to send `1.0` to backends that have not opted in.
* Merchants that don't implement a method may return `{"error":{"code":"not_implemented","status":501}}` — the gateway surfaces it as `commerce_backend_unavailable`. The [`@vaultgraph/sdk/adapter`](https://www.npmjs.com/package/@vaultgraph/sdk) helper emits this envelope automatically for any method you don't supply, so you can ship a partial implementation and grow into the surface incrementally.

***

## 10. Capability discovery

The gateway discovers which §6 methods you implement by calling a reserved `capabilities` method, then advertises only the matching tools to agents. Methods you don't report stay hidden until you implement them and the set is re-probed (re-saving the backend, or the refresh control on its settings page).

`capabilities` is a read: same envelope and signing as §2–§3, carrying no idempotency key.

Request to `{endpoint_url}/capabilities`:

```json theme={null}
{ "protocol": "1.0", "method": "capabilities", "params": {} }
```

Response `data`:

```json theme={null}
{ "methods": ["searchProducts", "getProduct", "createCheckout"] }
```

`methods` is the subset of §6 method names your endpoint serves. Names the gateway doesn't recognize are ignored.

The [`@vaultgraph/sdk/adapter`](https://www.npmjs.com/package/@vaultgraph/sdk) handler answers `capabilities` automatically by reporting exactly the methods you supplied — you do not implement it yourself. If you hand-roll the wire contract, you MUST respond to `capabilities`: an endpoint that rejects it (e.g. with `unknown_method`) reports no methods, so the gateway advertises no tools.

***

## 11. Reference implementation

The `@vaultgraph/sdk/adapter` helper wraps signing, replay rejection, dispatch, idempotency caching, and error serialization in a single typed factory:

```ts theme={null}
import { createAdapterHandler } from "@vaultgraph/sdk/adapter";

const handler = createAdapterHandler({
  signingSecret: process.env.VAULTGRAPH_SIGNING_SECRET!,
  idempotencyStore: kvBackedStore,

  // Authenticate the caller once per request. Verify `context.session_token`
  // and return a typed identity (any shape) or `null` for an anonymous caller.
  // The result is handed to every method as its final `session` argument.
  // Throw to reject the request outright.
  verifySession: (context) => verifySessionToken(context?.session_token),

  async searchProducts({ query, filters, pagination }) {
    /* ... */
  },
  async getProduct(id) {
    /* ... */
  },
  async createCheckout(input) {
    /* ... */
  },
  async getOrder(id, _context, session) {
    // Owner-scoped read: return the order only when the verified caller owns
    // it. Throw the same `order_not_found` for a missing OR non-owned id so
    // existence isn't leaked to a non-owner.
    const order = session ? await orderById(id) : null;
    if (!order || order.customerId !== session.customerId)
      throw { code: "order_not_found", status: 404 };
    return order;
  },
  async listOrders(input, _context, session) {
    // The verified `session` from `verifySession` is the final argument of
    // every method; scope the read to it. Reject an anonymous caller with
    // `authentication_required` (401) so the agent prompts the user to sign in,
    // rather than an empty page that reads as "you have no orders".
    // `input.pagination` carries the optional cursor/limit.
    if (!session)
      throw {
        code: "authentication_required",
        status: 401,
        detail: "Sign in to view your orders.",
      };
    return ordersForCustomer(session.customerId, input?.pagination);
  },
  // ...one method per row in §6 — every method is optional, anything you
  // skip auto-responds with `not_implemented` (501).
});

// Plug into your HTTP framework — handler is just (req) => res.
```

See [`@vaultgraph/sdk` on npm](https://www.npmjs.com/package/@vaultgraph/sdk) for the full surface.
