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 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 configuredendpoint_urljoined 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-8on both directions.
2. Request envelope
| 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. 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’scontext 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: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_tokentool argument. Every commerce tool accepts an optional opaquesession_tokenstring — the path for agent platforms whose connector configuration is static (no per-conversation headers). Supply the value to the conversation (e.g. as avg_sessionvariable the agent is instructed to copy verbatim), and the gateway strips it from the tool input — it never appears inparams— and forwards it ascontext.session_token. The token itself is provisioned by your application outside the conversation, or minted mid-conversation via 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: 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:
- Verify
context.session_tokenand resolve the owning customer. An unverifiable or absent token is an anonymous caller. - 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, orauthentication_required(401) forlistOrdersfrom 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. |
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 theUser-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}.
- The gateway takes the current Unix timestamp in seconds.
- It computes
signature = HMAC_SHA256(secret, "${timestamp}.${idempotencyKey}.${rawBody}")and hex-encodes it.${idempotencyKey}is the value of theX-VaultGraph-Idempotency-Keyheader (§5); on non-mutating methods, which carry no idempotency key, it is the empty string. - It sets
X-VaultGraph-Signature: t=<timestamp>,v1=<hex_signature>.
- Reject requests where
X-VaultGraph-Signatureis missing or malformed. - Reject requests where
|now - timestamp| > 5 minutes. This is the replay window. - Recompute the HMAC over the timestamp, the
X-VaultGraph-Idempotency-Keyheader value (empty string when absent), and the raw body bytes (not a re-serialized JSON), and reject on mismatch. Use a constant-time comparison.
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:| 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. |
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 sendsX-VaultGraph-Idempotency-Key: <uuid> on every mutating method:
createCheckoutaddLineItemsupdateLineItemremoveLineItemapplyDiscountsetFulfillmentsetBuyercompleteCheckoutrequestAuthenticationverifyAuthentication
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.
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) | 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. |
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) |
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. |
listOrders | no | { input: ListOrdersInput } (see Pagination) | OrderPage ({ orders: Order[], pagination?: { … } }). Owner-scoped — see Authorizing owner-scoped reads. |
requestAuthentication | yes | { email: string } | AuthChallenge ({ challenge_id: string }) — see Customer sign-in. |
verifyAuthentication | yes | { challenge_id: string, code: string } | AuthSession ({ session_token: string, expires_at?: string }) — see Customer sign-in. |
params and data shape ship from @vaultgraph/sdk/adapter — 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 {}:
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:
Buyer + optional BillingAddress — setBuyer. A buyer email is what later unlocks completion:
ShippingDestination — setFulfillment’s shipping_address (required for shipping methods; see Fulfillment):
SearchCatalogInput — searchProducts, all fields optional — see 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):
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.
pickup expectation puts the collection point’s address in destination, with instructions telling the shopper where to go:
OrderPage — returned by listOrders: { "orders": Order[], "pagination"?: { … } } — see Pagination.
Product — returned by getProduct and inside ProductPage.products. A product groups one or more purchasable Variants; 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:
ProductPage — returned by searchProducts: { "products": Product[], "pagination"?: { … } } — see 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.
Checkout lifecycle
A checkout carries astatus that moves through three states:
incomplete— the state oncreateCheckout, 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 bycompleteCheckout; the checkout is now an order and no longer mutable.
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-
completedcart withcheckout_not_open.
Cart mutations
addLineItemsmerges byitem.idandproperties: 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.updateLineItemwithquantity: 0removes the line — equivalent toremoveLineItem.
Catalog search
searchProducts takes { input: SearchCatalogInput }, where every field of SearchCatalogInput is optional — an empty input returns the first page of the whole catalog:
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>" }.cursorMUST be present wheneverhas_next_pageis true. - On the last page the merchant either omits
paginationentirely or returnspagination: { "has_next_page": false }.total_countMAY be included on any page. - The caller passes the returned
cursorstraight back asinput.pagination.cursoron the next call; the gateway forwards it unchanged.input.pagination.limitmust 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.
listOrders, page on top of the owner-scoping: resolve the caller’s customer from the request’s 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) without leaving the chat:
requestAuthentication—{ email: string }. Deliver a one-time code out of band (e.g. to the customer’s verified email) and return anAuthChallenge:{ "challenge_id": "<opaque>" }. You own the id encoding and where the pending challenge lives.- The customer reads the code and gives it to the agent.
verifyAuthentication—{ challenge_id: string, code: string }. Check the code against the challenge and return anAuthSession:{ "session_token": "<opaque>", "expires_at"?: "<ISO 8601>" }. Reject a wrong code with your own 4xx (e.g.invalid_code, 401).- The agent sends the token on the customer’s later calls; it reaches you as
context.session_token, where yourverifySessionhook authenticates it — exactly as a token provisioned outside the conversation would.
- Don’t leak which emails have accounts. Respond to an unknown email exactly as to a known one — return a
challenge_idand 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 anyerror.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.
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.
- network errors (DNS, TCP, TLS, socket close mid-response),
- response timeouts (the request aborted before headers),
- HTTP 5xx without a recognized error envelope.
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
protocolfield plus theX-VaultGraph-Versionheader. Additive payload changes (new methods, new optional fields ondata) 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 send1.0to 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 ascommerce_backend_unavailable. The@vaultgraph/sdk/adapterhelper 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 reservedcapabilities 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:
data:
methods is the subset of §6 method names your endpoint serves. Names the gateway doesn’t recognize are ignored.
The @vaultgraph/sdk/adapter 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:
@vaultgraph/sdk on npm for the full surface.